Skip to main content

mermaid_cli/runtime/
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 super::{
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    pub fn as_str(self) -> &'static str {
27        match self {
28            RuntimeClientSource::Daemon => "daemon",
29            RuntimeClientSource::Local => "local",
30        }
31    }
32}
33
34#[derive(Debug, Clone)]
35enum RuntimeClientMode {
36    PreferDaemon,
37    DaemonOnly,
38    LocalOnly,
39}
40
41#[derive(Debug, Clone)]
42pub struct RuntimeClient {
43    mode: RuntimeClientMode,
44    auth_token: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct RuntimeRead<T> {
49    pub source: RuntimeClientSource,
50    pub value: T,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct RuntimeHealth {
55    pub ok: bool,
56    pub service: String,
57    pub database: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct RuntimeSnapshot {
62    pub ok: bool,
63    pub database: String,
64    pub sessions: Vec<SessionRecord>,
65    pub tasks: Vec<TaskRecord>,
66    pub tool_runs: Vec<ToolRunRecord>,
67    pub processes: Vec<ProcessRecord>,
68    pub approvals: Vec<ApprovalRecord>,
69    pub checkpoints: Vec<CheckpointRecord>,
70    pub compactions: Vec<CompactionRecord>,
71    pub plugins: Vec<PluginInstallRecord>,
72    pub provider_probes: Vec<ProviderProbeRecord>,
73    pub pairings: Vec<PairingTokenRecord>,
74    pub safety: crate::app::SafetyConfig,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct RuntimeDashboardCounts {
79    pub pending_approvals: usize,
80    pub running_tasks: usize,
81    pub waiting_tasks: usize,
82    pub blocked_tasks: usize,
83    pub ready_processes: usize,
84    pub recent_checkpoints: usize,
85    pub installed_plugins: usize,
86    pub archived_approvals: usize,
87    pub archived_checkpoints: usize,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct RuntimeDashboard {
92    pub ok: bool,
93    pub health: RuntimeHealth,
94    pub safety: crate::app::SafetyConfig,
95    pub counts: RuntimeDashboardCounts,
96    pub sessions: Vec<SessionRecord>,
97    pub tasks: Vec<TaskRecord>,
98    pub tool_runs: Vec<ToolRunRecord>,
99    pub processes: Vec<ProcessRecord>,
100    pub approvals: Vec<ApprovalRecord>,
101    pub checkpoints: Vec<CheckpointRecord>,
102    pub compactions: Vec<CompactionRecord>,
103    pub plugins: Vec<PluginInstallRecord>,
104    pub provider_probes: Vec<ProviderProbeRecord>,
105    pub pairings: Vec<PairingTokenRecord>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct RuntimeDiagnostics {
110    #[serde(flatten)]
111    pub snapshot: RuntimeSnapshot,
112    pub mode: String,
113    pub hygiene: RuntimeDiagnosticsHygiene,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct RuntimeDiagnosticsHygiene {
118    pub preview: Value,
119    pub visible: RuntimeDiagnosticsVisible,
120    pub archived: RuntimeDiagnosticsArchived,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct RuntimeDiagnosticsVisible {
125    pub approvals: usize,
126    pub checkpoints: usize,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RuntimeDiagnosticsArchived {
131    pub approvals: usize,
132    pub checkpoints: usize,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct RuntimeHygienePreview {
137    pub ok: bool,
138    pub reason: String,
139    pub approvals: Vec<ApprovalRecord>,
140    pub checkpoints: Vec<CheckpointRecord>,
141    pub counts: RuntimeHygieneCounts,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct RuntimeHygieneCounts {
146    pub approvals: usize,
147    pub checkpoints: usize,
148    pub total: usize,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct RuntimeHygieneArchive {
153    pub ok: bool,
154    pub reason: String,
155    pub archived: RuntimeHygieneCounts,
156    pub matched: RuntimeHygieneCounts,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct RuntimeTaskDetail {
161    pub ok: bool,
162    pub task: TaskRecord,
163    pub events: Vec<TaskTimelineEvent>,
164    pub session: Option<SessionRecord>,
165    pub messages: Vec<MessageRecord>,
166    pub approvals: Vec<ApprovalRecord>,
167    pub checkpoints: Vec<CheckpointRecord>,
168    pub processes: Vec<ProcessRecord>,
169    pub tool_runs: Vec<ToolRunRecord>,
170    pub compactions: Vec<CompactionRecord>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct RuntimeApprovalDetail {
175    pub ok: bool,
176    pub approval: ApprovalRecord,
177    pub task: Option<TaskRecord>,
178    pub checkpoint: Option<CheckpointRecord>,
179    pub pending_action: Value,
180    pub args: Value,
181    pub changed_files: Value,
182    pub affected_paths: Vec<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct RuntimeCheckpointDetail {
187    pub ok: bool,
188    pub checkpoint: CheckpointRecord,
189    pub task: Option<TaskRecord>,
190    pub approval: Option<ApprovalRecord>,
191    pub pending_action: Value,
192    pub changed_files: Value,
193    pub affected_paths: Vec<String>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct RuntimeProcessLog {
198    pub ok: bool,
199    pub content: String,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct RuntimeProcessOpen {
204    pub ok: bool,
205    pub target: String,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct RuntimePorts {
210    pub ok: bool,
211    pub ports: String,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct RuntimeApprovalDecision {
216    pub ok: bool,
217    pub approval: Option<ApprovalRecord>,
218    pub replayed: bool,
219    pub summary: String,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct RuntimeCheckpointRestore {
224    pub ok: bool,
225    pub checkpoint: CheckpointManifest,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229struct RuntimeItems<T> {
230    ok: bool,
231    items: Vec<T>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct RuntimeOne<T> {
236    pub ok: bool,
237    pub item: T,
238}
239
240pub struct RuntimeService {
241    store: RuntimeStore,
242}
243
244impl RuntimeClient {
245    pub fn auto() -> Self {
246        Self {
247            mode: RuntimeClientMode::PreferDaemon,
248            auth_token: None,
249        }
250    }
251
252    pub fn daemon() -> Self {
253        Self {
254            mode: RuntimeClientMode::DaemonOnly,
255            auth_token: None,
256        }
257    }
258
259    pub fn daemon_with_token(token: impl Into<String>) -> Self {
260        Self {
261            mode: RuntimeClientMode::DaemonOnly,
262            auth_token: Some(token.into()),
263        }
264    }
265
266    pub fn local() -> Self {
267        Self {
268            mode: RuntimeClientMode::LocalOnly,
269            auth_token: None,
270        }
271    }
272
273    pub fn health(&self) -> Result<RuntimeRead<RuntimeHealth>> {
274        self.read(crate::runtime::DaemonRequest::Health.to_wire(), |service| {
275            service.health()
276        })
277    }
278
279    pub fn snapshot(&self) -> Result<RuntimeRead<RuntimeSnapshot>> {
280        self.read(
281            crate::runtime::DaemonRequest::Snapshot.to_wire(),
282            |service| service.snapshot(),
283        )
284    }
285
286    pub fn dashboard(&self) -> Result<RuntimeRead<RuntimeDashboard>> {
287        self.read(
288            crate::runtime::DaemonRequest::RuntimeDashboard.to_wire(),
289            |service| service.dashboard(),
290        )
291    }
292
293    pub fn diagnostics(&self) -> Result<RuntimeRead<RuntimeDiagnostics>> {
294        self.read(
295            crate::runtime::DaemonRequest::RuntimeDiagnostics.to_wire(),
296            |service| service.diagnostics(),
297        )
298    }
299
300    pub fn hygiene_preview(&self) -> Result<RuntimeRead<RuntimeHygienePreview>> {
301        self.read(
302            crate::runtime::DaemonRequest::RuntimeHygienePreview.to_wire(),
303            |service| service.hygiene_preview(),
304        )
305    }
306
307    pub fn hygiene_archive(&self) -> Result<RuntimeRead<RuntimeHygieneArchive>> {
308        self.read_inner(
309            crate::runtime::DaemonRequest::RuntimeHygieneArchive.to_wire(),
310            true,
311            |service| service.hygiene_archive(),
312        )
313    }
314
315    pub fn task_detail(&self, id: &str) -> Result<RuntimeRead<RuntimeTaskDetail>> {
316        self.read(
317            crate::runtime::DaemonRequest::RuntimeTaskDetail { id: id.to_string() }.to_wire(),
318            |service| service.task_detail(id),
319        )
320    }
321
322    pub fn approval_detail(&self, id: &str) -> Result<RuntimeRead<RuntimeApprovalDetail>> {
323        self.read(
324            crate::runtime::DaemonRequest::RuntimeApprovalDetail { id: id.to_string() }.to_wire(),
325            |service| service.approval_detail(id),
326        )
327    }
328
329    pub fn checkpoint_detail(&self, id: &str) -> Result<RuntimeRead<RuntimeCheckpointDetail>> {
330        self.read(
331            crate::runtime::DaemonRequest::RuntimeCheckpointDetail { id: id.to_string() }.to_wire(),
332            |service| service.checkpoint_detail(id),
333        )
334    }
335
336    pub fn list_tasks(&self, limit: usize) -> Result<RuntimeRead<Vec<TaskRecord>>> {
337        self.list(
338            crate::runtime::DaemonRequest::RuntimeTasks {
339                limit: Some(limit as u64),
340            }
341            .to_wire(),
342            |service| service.list_tasks(limit),
343        )
344    }
345
346    pub fn list_processes(&self, limit: usize) -> Result<RuntimeRead<Vec<ProcessRecord>>> {
347        self.list(
348            crate::runtime::DaemonRequest::RuntimeProcesses {
349                limit: Some(limit as u64),
350            }
351            .to_wire(),
352            |service| service.list_processes(limit),
353        )
354    }
355
356    pub fn list_approvals(&self) -> Result<RuntimeRead<Vec<ApprovalRecord>>> {
357        self.list(
358            crate::runtime::DaemonRequest::RuntimeApprovals.to_wire(),
359            |service| service.list_approvals(),
360        )
361    }
362
363    pub fn list_tool_runs(&self, limit: usize) -> Result<RuntimeRead<Vec<ToolRunRecord>>> {
364        self.list(
365            crate::runtime::DaemonRequest::RuntimeToolRuns {
366                limit: Some(limit as u64),
367            }
368            .to_wire(),
369            |service| service.list_tool_runs(limit),
370        )
371    }
372
373    pub fn list_checkpoints(&self, limit: usize) -> Result<RuntimeRead<Vec<CheckpointRecord>>> {
374        self.list(
375            crate::runtime::DaemonRequest::RuntimeCheckpoints {
376                limit: Some(limit as u64),
377            }
378            .to_wire(),
379            |service| service.list_checkpoints(limit),
380        )
381    }
382
383    pub fn list_plugins(&self) -> Result<RuntimeRead<Vec<PluginInstallRecord>>> {
384        self.list(
385            crate::runtime::DaemonRequest::RuntimePlugins.to_wire(),
386            |service| service.list_plugins(),
387        )
388    }
389
390    pub fn process_log(&self, id: &str, tail_bytes: Option<u64>) -> Result<RuntimeProcessLog> {
391        self.action(
392            crate::runtime::DaemonRequest::Logs {
393                id: id.to_string(),
394                tail_bytes,
395            }
396            .to_wire(),
397            |service| service.process_log(id, tail_bytes),
398        )
399    }
400
401    pub fn stop_process(&self, id: &str) -> Result<RuntimeOne<ProcessRecord>> {
402        // Non-idempotent: `terminate_tree` must not fire twice (RC-G/F25).
403        self.action_authed_non_idempotent(
404            crate::runtime::DaemonRequest::StopProcess { id: id.to_string() }.to_wire(),
405            |service| {
406                service
407                    .stop_process(id)
408                    .map(|item| RuntimeOne { ok: true, item })
409            },
410        )
411    }
412
413    pub fn restart_process(&self, id: &str) -> Result<RuntimeOne<ProcessRecord>> {
414        // Non-idempotent: kills then respawns — a duplicate run double-signals
415        // (possibly a since-reused PID) and can spawn two servers (RC-G/F25).
416        self.action_authed_non_idempotent(
417            crate::runtime::DaemonRequest::RestartProcess { id: id.to_string() }.to_wire(),
418            |service| {
419                service
420                    .restart_process(id)
421                    .map(|item| RuntimeOne { ok: true, item })
422            },
423        )
424    }
425
426    pub fn open_process(&self, id: &str) -> Result<RuntimeProcessOpen> {
427        self.action_authed(
428            crate::runtime::DaemonRequest::OpenProcess { id: id.to_string() }.to_wire(),
429            |service| service.open_process(id),
430        )
431    }
432
433    pub fn ports(&self) -> Result<RuntimePorts> {
434        self.action(crate::runtime::DaemonRequest::Ports.to_wire(), |service| {
435            service.ports()
436        })
437    }
438
439    pub fn approve(&self, id: &str) -> Result<RuntimeApprovalDecision> {
440        // Non-idempotent: replays the approved action, so a duplicate run could
441        // execute that side effect twice (RC-G/F25).
442        self.action_authed_non_idempotent(
443            crate::runtime::DaemonRequest::Approve { id: id.to_string() }.to_wire(),
444            |_service| RuntimeService::approval_decision(approve_and_replay(id)?),
445        )
446    }
447
448    pub fn deny(&self, id: &str) -> Result<RuntimeApprovalDecision> {
449        self.action_authed(
450            crate::runtime::DaemonRequest::Deny { id: id.to_string() }.to_wire(),
451            |_service| RuntimeService::approval_decision(deny_approval(id)?),
452        )
453    }
454
455    pub fn restore_checkpoint(&self, id: &str) -> Result<RuntimeCheckpointRestore> {
456        // Non-idempotent: rewrites working-tree files from the snapshot — a
457        // duplicate run could clobber edits made between the two runs (RC-G/F25).
458        self.action_authed_non_idempotent(
459            crate::runtime::DaemonRequest::RestoreCheckpoint { id: id.to_string() }.to_wire(),
460            |_service| {
461                Ok(RuntimeCheckpointRestore {
462                    ok: true,
463                    checkpoint: restore_checkpoint(id)?,
464                })
465            },
466        )
467    }
468
469    pub fn set_plugin_enabled(&self, id: &str, enabled: bool) -> Result<()> {
470        self.action_authed::<Value, _>(
471            crate::runtime::DaemonRequest::SetPluginEnabled {
472                id: id.to_string(),
473                enabled,
474            }
475            .to_wire(),
476            |service| {
477                service.set_plugin_enabled(id, enabled)?;
478                Ok(json!({"ok": true}))
479            },
480        )?;
481        Ok(())
482    }
483
484    pub fn model_info(&self, model: &str) -> Result<Value> {
485        self.action(
486            crate::runtime::DaemonRequest::ModelInfo {
487                model: model.to_string(),
488            }
489            .to_wire(),
490            |service| Ok(json!({"ok": true, "model": service.model_info(model)})),
491        )
492    }
493
494    pub fn set_safety_mode(&self, mode: &str) -> Result<Value> {
495        self.action_authed(
496            crate::runtime::DaemonRequest::SetSafetyMode {
497                mode: mode.to_string(),
498            }
499            .to_wire(),
500            |service| {
501                let safety = service.set_safety_mode(mode)?;
502                Ok(json!({"ok": true, "safety": safety}))
503            },
504        )
505    }
506
507    fn list<T, F>(&self, body: Value, local: F) -> Result<RuntimeRead<Vec<T>>>
508    where
509        T: DeserializeOwned,
510        F: FnOnce(&RuntimeService) -> Result<Vec<T>>,
511    {
512        self.read(body, |service| {
513            local(service).map(|items| RuntimeItems { ok: true, items })
514        })
515        .map(|read| RuntimeRead {
516            source: read.source,
517            value: read.value.items,
518        })
519    }
520
521    fn read<T, F>(&self, body: Value, local: F) -> Result<RuntimeRead<T>>
522    where
523        T: DeserializeOwned,
524        F: FnOnce(&RuntimeService) -> Result<T>,
525    {
526        // #21: attach the pairing token when the client has one. Reads are now
527        // gated server-side (`command_requires_auth`), so a `daemon_with_token`
528        // client must send its token to read off the socket; a tokenless
529        // `auto()` client sends nothing and `PreferDaemon` falls back to a local
530        // DB read on the daemon's auth rejection. `request_daemon` only attaches
531        // the token if one is present, so tokenless clients are unchanged, and
532        // ungated commands (`health`/`ports`) are served regardless.
533        self.read_inner(body, true, local)
534    }
535
536    fn action<T, F>(&self, body: Value, local: F) -> Result<T>
537    where
538        T: DeserializeOwned,
539        F: FnOnce(&RuntimeService) -> Result<T>,
540    {
541        self.action_inner(body, false, true, local)
542    }
543
544    fn action_authed<T, F>(&self, body: Value, local: F) -> Result<T>
545    where
546        T: DeserializeOwned,
547        F: FnOnce(&RuntimeService) -> Result<T>,
548    {
549        self.action_inner(body, true, true, local)
550    }
551
552    /// Like [`Self::action_authed`], but for a NON-idempotent side effect
553    /// (`stop`/`restart`/`approve`/`restore`). RC-G/F25: under `PreferDaemon` a
554    /// local fallback *re-runs* the action, which is only safe when the daemon
555    /// never received the request (a pre-send connection failure). On a
556    /// post-send or ambiguous failure the daemon MAY have already executed it,
557    /// so we surface an error instead of risking a duplicate side effect.
558    fn action_authed_non_idempotent<T, F>(&self, body: Value, local: F) -> Result<T>
559    where
560        T: DeserializeOwned,
561        F: FnOnce(&RuntimeService) -> Result<T>,
562    {
563        self.action_inner(body, true, false, local)
564    }
565
566    fn read_inner<T, F>(&self, body: Value, authed: bool, local: F) -> Result<RuntimeRead<T>>
567    where
568        T: DeserializeOwned,
569        F: FnOnce(&RuntimeService) -> Result<T>,
570    {
571        match self.mode {
572            RuntimeClientMode::LocalOnly => RuntimeService::open_default()
573                .and_then(|service| local(&service))
574                .map(|value| RuntimeRead {
575                    source: RuntimeClientSource::Local,
576                    value,
577                }),
578            RuntimeClientMode::DaemonOnly => {
579                self.request_daemon(body, authed).map(|value| RuntimeRead {
580                    source: RuntimeClientSource::Daemon,
581                    value,
582                })
583            },
584            RuntimeClientMode::PreferDaemon => match self.request_daemon(body, authed) {
585                Ok(value) => Ok(RuntimeRead {
586                    source: RuntimeClientSource::Daemon,
587                    value,
588                }),
589                Err(_) => RuntimeService::open_default()
590                    .and_then(|service| local(&service))
591                    .map(|value| RuntimeRead {
592                        source: RuntimeClientSource::Local,
593                        value,
594                    }),
595            },
596        }
597    }
598
599    fn action_inner<T, F>(&self, body: Value, authed: bool, idempotent: bool, local: F) -> Result<T>
600    where
601        T: DeserializeOwned,
602        F: FnOnce(&RuntimeService) -> Result<T>,
603    {
604        match self.mode {
605            RuntimeClientMode::LocalOnly => {
606                RuntimeService::open_default().and_then(|service| local(&service))
607            },
608            RuntimeClientMode::DaemonOnly => self.request_daemon(body, authed),
609            RuntimeClientMode::PreferDaemon => match self.request_daemon(body, authed) {
610                Ok(value) => Ok(value),
611                Err(err) => {
612                    // RC-G/F25: an idempotent or read-only action falls back to
613                    // a local run freely. A non-idempotent one falls back ONLY
614                    // when the failure is a pre-send connection failure (the
615                    // request never reached the daemon); otherwise the daemon
616                    // may have already executed it and re-running locally would
617                    // double-fire the side effect (e.g. a second
618                    // `terminate_tree` on a since-reused PID). Conservative
619                    // default: treat any non-connect failure as "may have run".
620                    if idempotent || daemon_failure_is_pre_send(&err) {
621                        RuntimeService::open_default().and_then(|service| local(&service))
622                    } else {
623                        Err(err.context(
624                            "daemon request failed after the action may have already run; \
625                             not re-running it locally to avoid a duplicate side effect — \
626                             retry once the daemon is reachable",
627                        ))
628                    }
629                },
630            },
631        }
632    }
633
634    fn request_daemon<T: DeserializeOwned>(&self, mut body: Value, authed: bool) -> Result<T> {
635        if authed && let Some(token) = self.auth_token.as_ref() {
636            body["auth"] = json!({ "token": token });
637        }
638        let value = request_daemon_json(body)?;
639        serde_json::from_value(value).context("daemon response had unexpected shape")
640    }
641}
642
643impl RuntimeService {
644    pub fn open_default() -> Result<Self> {
645        Ok(Self {
646            store: RuntimeStore::open_default()?,
647        })
648    }
649
650    pub fn from_store(store: RuntimeStore) -> Self {
651        Self { store }
652    }
653
654    pub fn health(&self) -> Result<RuntimeHealth> {
655        Ok(RuntimeHealth {
656            ok: true,
657            service: "mermaidd".to_string(),
658            database: self.store.path().display().to_string(),
659        })
660    }
661
662    pub fn snapshot(&self) -> Result<RuntimeSnapshot> {
663        Ok(RuntimeSnapshot {
664            ok: true,
665            database: self.store.path().display().to_string(),
666            sessions: self.store.sessions().list(100)?,
667            tasks: self.store.tasks().list(100)?,
668            tool_runs: self.store.tool_runs().list(100)?,
669            processes: self.store.processes().list(100)?,
670            approvals: self.store.approvals().list_all(100)?,
671            checkpoints: self.store.checkpoints().list_all(100)?,
672            compactions: self.store.compactions().list(100)?,
673            plugins: self.store.plugins().list()?,
674            provider_probes: self.store.provider_probes().list(None, None)?,
675            // Redact token hashes — this snapshot is served over the local
676            // socket to same-UID processes without auth.
677            pairings: self.store.pairing_tokens().list_redacted()?,
678            safety: crate::app::load_config().unwrap_or_default().safety,
679        })
680    }
681
682    pub fn dashboard(&self) -> Result<RuntimeDashboard> {
683        let sessions = self.store.sessions().list(100)?;
684        let tasks = self.store.tasks().list(200)?;
685        let tool_runs = self.store.tool_runs().list(100)?;
686        let processes = self.store.processes().list(100)?;
687        let approvals = self.store.approvals().list_pending()?;
688        let checkpoints = self.store.checkpoints().list(100)?;
689        let compactions = self.store.compactions().list(100)?;
690        let plugins = self.store.plugins().list()?;
691        let provider_probes = self.store.provider_probes().list(None, None)?;
692        let pairings = self.store.pairing_tokens().list_redacted()?;
693
694        let running_tasks = tasks
695            .iter()
696            .filter(|task| task.status == TaskStatus::Running)
697            .count();
698        let waiting_tasks = tasks
699            .iter()
700            .filter(|task| task.status == TaskStatus::WaitingForApproval)
701            .count();
702        let blocked_tasks = tasks
703            .iter()
704            .filter(|task| matches!(task.status, TaskStatus::Blocked | TaskStatus::Failed))
705            .count();
706        let ready_processes = processes
707            .iter()
708            .filter(|process| process.detected_url.is_some())
709            .count();
710
711        Ok(RuntimeDashboard {
712            ok: true,
713            health: self.health()?,
714            safety: crate::app::load_config().unwrap_or_default().safety,
715            counts: RuntimeDashboardCounts {
716                pending_approvals: approvals.len(),
717                running_tasks,
718                waiting_tasks,
719                blocked_tasks,
720                ready_processes,
721                recent_checkpoints: checkpoints.len(),
722                installed_plugins: plugins.len(),
723                archived_approvals: self.store.approvals().count_archived()?,
724                archived_checkpoints: self.store.checkpoints().count_archived()?,
725            },
726            sessions,
727            tasks,
728            tool_runs,
729            processes,
730            approvals,
731            checkpoints,
732            compactions,
733            plugins,
734            provider_probes,
735            pairings,
736        })
737    }
738
739    pub fn diagnostics(&self) -> Result<RuntimeDiagnostics> {
740        let snapshot = self.snapshot()?;
741        let hygiene = self.hygiene_preview()?;
742        Ok(RuntimeDiagnostics {
743            snapshot,
744            mode: "diagnostics".to_string(),
745            hygiene: RuntimeDiagnosticsHygiene {
746                preview: serde_json::to_value(hygiene)?,
747                visible: RuntimeDiagnosticsVisible {
748                    approvals: self.store.approvals().list_pending()?.len(),
749                    checkpoints: self.store.checkpoints().list(100)?.len(),
750                },
751                archived: RuntimeDiagnosticsArchived {
752                    approvals: self.store.approvals().count_archived()?,
753                    checkpoints: self.store.checkpoints().count_archived()?,
754                },
755            },
756        })
757    }
758
759    pub fn hygiene_preview(&self) -> Result<RuntimeHygienePreview> {
760        let preview = self.raw_hygiene_preview()?;
761        Ok(RuntimeHygienePreview {
762            ok: true,
763            reason: runtime_hygiene_reason().to_string(),
764            counts: RuntimeHygieneCounts {
765                approvals: preview.approvals.len(),
766                checkpoints: preview.checkpoints.len(),
767                total: preview.approvals.len() + preview.checkpoints.len(),
768            },
769            approvals: preview.approvals,
770            checkpoints: preview.checkpoints,
771        })
772    }
773
774    pub fn hygiene_archive(&self) -> Result<RuntimeHygieneArchive> {
775        let preview = self.raw_hygiene_preview()?;
776        let approval_ids = preview
777            .approvals
778            .iter()
779            .map(|approval| approval.id.clone())
780            .collect::<Vec<_>>();
781        let checkpoint_ids = preview
782            .checkpoints
783            .iter()
784            .map(|checkpoint| checkpoint.id.clone())
785            .collect::<Vec<_>>();
786        let reason = runtime_hygiene_reason();
787        let approvals_archived = self.store.approvals().archive(&approval_ids, reason)?;
788        let checkpoints_archived = self.store.checkpoints().archive(&checkpoint_ids, reason)?;
789
790        Ok(RuntimeHygieneArchive {
791            ok: true,
792            reason: reason.to_string(),
793            archived: RuntimeHygieneCounts {
794                approvals: approvals_archived,
795                checkpoints: checkpoints_archived,
796                total: approvals_archived + checkpoints_archived,
797            },
798            matched: RuntimeHygieneCounts {
799                approvals: approval_ids.len(),
800                checkpoints: checkpoint_ids.len(),
801                total: approval_ids.len() + checkpoint_ids.len(),
802            },
803        })
804    }
805
806    pub fn task_detail(&self, id: &str) -> Result<RuntimeTaskDetail> {
807        let task = self
808            .store
809            .tasks()
810            .get(id)?
811            .with_context(|| format!("task not found: {}", id))?;
812        let events = self.store.tasks().events(id)?;
813        let session = match task.conversation_id.as_deref() {
814            Some(session_id) => self.store.sessions().get(session_id)?,
815            None => None,
816        };
817        let messages = match task.conversation_id.as_deref() {
818            Some(session_id) => self.store.messages().list_for_session(session_id)?,
819            None => Vec::new(),
820        };
821        let approvals = self
822            .store
823            .approvals()
824            .list_pending()?
825            .into_iter()
826            .filter(|approval| approval.task_id.as_deref() == Some(id))
827            .collect::<Vec<_>>();
828        let checkpoints = self
829            .store
830            .checkpoints()
831            .list(200)?
832            .into_iter()
833            .filter(|checkpoint| checkpoint.task_id.as_deref() == Some(id))
834            .collect::<Vec<_>>();
835        let processes = self
836            .store
837            .processes()
838            .list(200)?
839            .into_iter()
840            .filter(|process| process.task_id.as_deref() == Some(id))
841            .collect::<Vec<_>>();
842        let tool_runs = self
843            .store
844            .tool_runs()
845            .list(200)?
846            .into_iter()
847            .filter(|tool_run| tool_run.task_id.as_deref() == Some(id))
848            .collect::<Vec<_>>();
849        let compactions = self
850            .store
851            .compactions()
852            .list(200)?
853            .into_iter()
854            .filter(|compaction| compaction.task_id.as_deref() == Some(id))
855            .collect::<Vec<_>>();
856
857        Ok(RuntimeTaskDetail {
858            ok: true,
859            task,
860            events,
861            session,
862            messages,
863            approvals,
864            checkpoints,
865            processes,
866            tool_runs,
867            compactions,
868        })
869    }
870
871    pub fn approval_detail(&self, id: &str) -> Result<RuntimeApprovalDetail> {
872        let approval = self
873            .store
874            .approvals()
875            .get(id)?
876            .with_context(|| format!("approval not found: {}", id))?;
877        let checkpoint = match approval.checkpoint_id.as_deref() {
878            Some(checkpoint_id) => self.store.checkpoints().get(checkpoint_id)?,
879            None => None,
880        };
881        let task = match approval.task_id.as_deref() {
882            Some(task_id) => self.store.tasks().get(task_id)?,
883            None => None,
884        };
885        let pending_action = parse_optional_json(approval.pending_action_json.as_deref());
886        let args = parse_optional_json(approval.args_summary.as_deref());
887        let changed_files = checkpoint
888            .as_ref()
889            .map(|checkpoint| parse_optional_json(Some(&checkpoint.changed_files_json)))
890            .unwrap_or(Value::Null);
891        let affected_paths = affected_paths_from_json(&pending_action, &changed_files);
892
893        Ok(RuntimeApprovalDetail {
894            ok: true,
895            approval,
896            task,
897            checkpoint,
898            pending_action,
899            args,
900            changed_files,
901            affected_paths,
902        })
903    }
904
905    pub fn checkpoint_detail(&self, id: &str) -> Result<RuntimeCheckpointDetail> {
906        let checkpoint = self
907            .store
908            .checkpoints()
909            .get(id)?
910            .with_context(|| format!("checkpoint not found: {}", id))?;
911        let approval = match checkpoint.approval_id.as_deref() {
912            Some(approval_id) => self.store.approvals().get(approval_id)?,
913            None => None,
914        };
915        let task = match checkpoint.task_id.as_deref() {
916            Some(task_id) => self.store.tasks().get(task_id)?,
917            None => None,
918        };
919        let pending_action = parse_optional_json(checkpoint.pending_action_json.as_deref());
920        let changed_files = parse_optional_json(Some(&checkpoint.changed_files_json));
921        let affected_paths = affected_paths_from_json(&pending_action, &changed_files);
922
923        Ok(RuntimeCheckpointDetail {
924            ok: true,
925            checkpoint,
926            task,
927            approval,
928            pending_action,
929            changed_files,
930            affected_paths,
931        })
932    }
933
934    pub fn list_tasks(&self, limit: usize) -> Result<Vec<TaskRecord>> {
935        self.store.tasks().list(limit)
936    }
937
938    pub fn list_processes(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
939        self.store.processes().list(limit)
940    }
941
942    pub fn list_approvals(&self) -> Result<Vec<ApprovalRecord>> {
943        self.store.approvals().list_pending()
944    }
945
946    pub fn list_tool_runs(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
947        self.store.tool_runs().list(limit)
948    }
949
950    pub fn list_checkpoints(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
951        self.store.checkpoints().list(limit)
952    }
953
954    pub fn list_plugins(&self) -> Result<Vec<PluginInstallRecord>> {
955        self.store.plugins().list()
956    }
957
958    pub fn process_log(&self, id: &str, tail_bytes: Option<u64>) -> Result<RuntimeProcessLog> {
959        let process = self
960            .store
961            .processes()
962            .get(id)?
963            .with_context(|| format!("process not found: {}", id))?;
964        let path = process
965            .log_path
966            .with_context(|| format!("process has no log path: {}", id))?;
967        let tail = tail_bytes.unwrap_or(32 * 1024).min(512 * 1024);
968        // Seek to the tail rather than reading the whole file: a long-running
969        // dev server can produce a multi-GB log, and we only ever return the
970        // last `tail` bytes. `std::fs::read` would have pinned the whole file
971        // in RAM first (#41).
972        use std::io::{Read, Seek, SeekFrom};
973        let mut file =
974            std::fs::File::open(&path).with_context(|| format!("failed to read {}", path))?;
975        let len = file.metadata().map(|m| m.len()).unwrap_or(0);
976        let start = len.saturating_sub(tail);
977        if start > 0 {
978            file.seek(SeekFrom::Start(start))
979                .with_context(|| format!("failed to seek {}", path))?;
980        }
981        let mut bytes = Vec::new();
982        file.take(tail)
983            .read_to_end(&mut bytes)
984            .with_context(|| format!("failed to read {}", path))?;
985        Ok(RuntimeProcessLog {
986            ok: true,
987            content: String::from_utf8_lossy(&bytes).into_owned(),
988        })
989    }
990
991    pub fn stop_process(&self, id: &str) -> Result<ProcessRecord> {
992        let process = self
993            .store
994            .processes()
995            .get(id)?
996            .with_context(|| format!("process not found: {}", id))?;
997        // Best-effort group kill (SIGTERM → grace → SIGKILL) so workers the dev
998        // server forked die with it; then mark the row stopped regardless.
999        crate::utils::terminate_tree_blocking(process.pid, crate::utils::Grace::Graceful);
1000        self.store.processes().upsert(NewProcess {
1001            id: Some(process.id.clone()),
1002            task_id: process.task_id.clone(),
1003            pid: process.pid,
1004            command: process.command.clone(),
1005            cwd: process.cwd.clone(),
1006            log_path: process.log_path.clone(),
1007            detected_url: process.detected_url.clone(),
1008            status: ProcessStatus::Exited,
1009            health: Some("stopped".to_string()),
1010        })
1011    }
1012
1013    pub fn restart_process(&self, id: &str) -> Result<ProcessRecord> {
1014        let process = self
1015            .store
1016            .processes()
1017            .get(id)?
1018            .with_context(|| format!("process not found: {}", id))?;
1019        // #63: the command comes from a `processes` row; a tampered DB could swap
1020        // in a destructive command. Refuse to respawn it (mirrors exec.rs's
1021        // execute_command pre-check) before killing or spawning anything.
1022        anyhow::ensure!(
1023            !crate::runtime::is_destructive_command(&process.command),
1024            "refusing to restart process {id}: command flagged destructive: {:?}",
1025            process.command
1026        );
1027        crate::utils::terminate_tree_blocking(process.pid, crate::utils::Grace::Graceful);
1028        // Wait (bounded) for the old PID to actually exit before respawning —
1029        // the kill above is fire-and-forget (async signal / taskkill), so an
1030        // immediate respawn can race the old process for its listening port.
1031        // Poll until it's gone, then fail-open after 5s.
1032        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1033        while pid_alive(process.pid) {
1034            if std::time::Instant::now() >= deadline {
1035                tracing::warn!(
1036                    pid = process.pid,
1037                    "restart_process: old pid still alive after 5s; respawning anyway"
1038                );
1039                break;
1040            }
1041            std::thread::sleep(std::time::Duration::from_millis(100));
1042        }
1043        let mut command = Command::new("sh");
1044        command.arg("-c").arg(&process.command);
1045        // Lead a new process group so `/stop`/`/restart` can group-kill the
1046        // whole tree (the dev server plus any worker it forks), matching the
1047        // foreground exec path. Windows kills the tree by pid via taskkill /T.
1048        #[cfg(unix)]
1049        command.process_group(0);
1050        if let Some(cwd) = process.cwd.as_deref() {
1051            command.current_dir(cwd);
1052        }
1053        if let Some(path) = process.log_path.as_deref() {
1054            let file = std::fs::OpenOptions::new()
1055                .create(true)
1056                .append(true)
1057                .open(path)
1058                .with_context(|| format!("failed to open process log {}", path))?;
1059            let stderr = file.try_clone()?;
1060            command
1061                .stdout(Stdio::from(file))
1062                .stderr(Stdio::from(stderr));
1063        }
1064        let child = command.spawn()?;
1065        self.store.processes().upsert(NewProcess {
1066            id: Some(process.id),
1067            task_id: process.task_id,
1068            pid: child.id(),
1069            command: process.command,
1070            cwd: process.cwd,
1071            log_path: process.log_path,
1072            detected_url: process.detected_url,
1073            status: ProcessStatus::Running,
1074            health: Some("restarted".to_string()),
1075        })
1076    }
1077
1078    pub fn open_process(&self, id: &str) -> Result<RuntimeProcessOpen> {
1079        let process = self
1080            .store
1081            .processes()
1082            .get(id)?
1083            .with_context(|| format!("process not found: {}", id))?;
1084        let target = process
1085            .detected_url
1086            .or(process.log_path)
1087            .with_context(|| format!("process has no URL or log path: {}", id))?;
1088        validate_open_target(&target)?;
1089        crate::utils::open_file(target.clone());
1090        Ok(RuntimeProcessOpen { ok: true, target })
1091    }
1092
1093    pub fn resolve_open_target(&self, target: &str) -> Result<String> {
1094        if let Some(process) = self.store.processes().get(target)?
1095            && let Some(target) = process.detected_url.or(process.log_path)
1096        {
1097            return Ok(target);
1098        }
1099        Ok(target.to_string())
1100    }
1101
1102    pub fn ports(&self) -> Result<RuntimePorts> {
1103        let output = if cfg!(windows) {
1104            Command::new("netstat").arg("-ano").output()?
1105        } else {
1106            Command::new("sh")
1107                .arg("-c")
1108                .arg("ss -ltnp 2>/dev/null || netstat -ltnp 2>/dev/null || true")
1109                .output()?
1110        };
1111        Ok(RuntimePorts {
1112            ok: true,
1113            ports: String::from_utf8_lossy(&output.stdout).into_owned(),
1114        })
1115    }
1116
1117    pub fn set_plugin_enabled(&self, id: &str, enabled: bool) -> Result<()> {
1118        self.store.plugins().set_enabled(id, enabled)?;
1119        super::write_plugin_lockfile()?;
1120        Ok(())
1121    }
1122
1123    pub fn set_safety_mode(&self, mode: &str) -> Result<crate::app::SafetyConfig> {
1124        let parsed = super::SafetyMode::parse(mode)
1125            .ok_or_else(|| anyhow::anyhow!("unknown safety mode: {}", mode))?;
1126        // Rewrite only `safety.mode` in the user file (never the whole merged
1127        // config), then report back the resulting user-scope safety section.
1128        crate::app::update_user_config_key(
1129            &["safety", "mode"],
1130            toml::Value::String(parsed.as_str().to_string()),
1131        )?;
1132        Ok(crate::app::load_config()?.safety)
1133    }
1134
1135    pub fn model_info(&self, model: &str) -> Value {
1136        let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(model);
1137        for (key, value, confidence) in [
1138            (
1139                "supports_tools",
1140                snapshot.supports_tools.to_string(),
1141                "static",
1142            ),
1143            (
1144                "supports_vision",
1145                snapshot.supports_vision.to_string(),
1146                "static",
1147            ),
1148            ("reasoning", snapshot.reasoning.clone(), "static"),
1149            (
1150                "max_context_tokens",
1151                snapshot
1152                    .max_context_tokens
1153                    .map(|value| value.to_string())
1154                    .unwrap_or_else(|| "unknown".to_string()),
1155                "static",
1156            ),
1157        ] {
1158            let _ = self.store.provider_probes().upsert(NewProviderProbe {
1159                provider: snapshot.provider.clone(),
1160                model_id: snapshot.model.clone(),
1161                capability_key: key.to_string(),
1162                capability_value: value,
1163                confidence: confidence.to_string(),
1164                error: None,
1165            });
1166        }
1167        if let Some(profile) = crate::models::lookup_provider(&snapshot.provider) {
1168            record_static_provider_probes(
1169                &self.store,
1170                profile,
1171                &snapshot.provider,
1172                &snapshot.model,
1173            );
1174        }
1175        json!({
1176            "id": model,
1177            "provider": snapshot.provider,
1178            "model": snapshot.model,
1179            "supports_tools": snapshot.supports_tools,
1180            "supports_vision": snapshot.supports_vision,
1181            "reasoning": snapshot.reasoning,
1182            "max_context_tokens": snapshot.max_context_tokens,
1183        })
1184    }
1185
1186    pub fn approval_decision(result: ApprovalReplayResult) -> Result<RuntimeApprovalDecision> {
1187        Ok(RuntimeApprovalDecision {
1188            ok: true,
1189            approval: result.approval,
1190            replayed: result.replayed,
1191            summary: result.summary,
1192        })
1193    }
1194
1195    fn raw_hygiene_preview(&self) -> Result<RawRuntimeHygienePreview> {
1196        let checkpoints = self
1197            .store
1198            .checkpoints()
1199            .list_all(10_000)?
1200            .into_iter()
1201            .filter(|checkpoint| checkpoint.archived_at.is_none())
1202            .collect::<Vec<_>>();
1203        let test_checkpoint_ids = checkpoints
1204            .iter()
1205            .filter(|checkpoint| is_runtime_hygiene_checkpoint(checkpoint))
1206            .map(|checkpoint| checkpoint.id.clone())
1207            .collect::<HashSet<_>>();
1208        let approvals = self
1209            .store
1210            .approvals()
1211            .list_all(10_000)?
1212            .into_iter()
1213            .filter(|approval| approval.archived_at.is_none())
1214            .filter(|approval| is_runtime_hygiene_approval(approval, &test_checkpoint_ids))
1215            .collect::<Vec<_>>();
1216        let checkpoints = checkpoints
1217            .into_iter()
1218            .filter(is_runtime_hygiene_checkpoint)
1219            .collect::<Vec<_>>();
1220
1221        Ok(RawRuntimeHygienePreview {
1222            approvals,
1223            checkpoints,
1224        })
1225    }
1226}
1227
1228#[derive(Debug)]
1229struct RawRuntimeHygienePreview {
1230    approvals: Vec<ApprovalRecord>,
1231    checkpoints: Vec<CheckpointRecord>,
1232}
1233
1234pub fn runtime_hygiene_reason() -> &'static str {
1235    "runtime hygiene: test/dev artifact"
1236}
1237
1238/// Record the statically-known capability probes a provider profile implies.
1239/// Both `mermaid providers probe` and the runtime client's model view persist
1240/// this same table, so it lives here once — adding a capability in one place
1241/// can no longer silently miss the other.
1242pub fn record_static_provider_probes(
1243    store: &RuntimeStore,
1244    profile: &crate::models::ProviderProfile,
1245    provider: &str,
1246    model_id: &str,
1247) {
1248    for (key, value) in [
1249        (
1250            "max_output_tokens_param",
1251            format!("{:?}", profile.max_tokens_param),
1252        ),
1253        (
1254            "parallel_tool_calls",
1255            (!profile.disable_parallel_tool_calls_for.contains(&model_id)).to_string(),
1256        ),
1257        (
1258            "reasoning_parameter_shape",
1259            format!("{:?}", profile.reasoning_strategy),
1260        ),
1261        (
1262            "streaming_usage_available",
1263            "provider_dependent".to_string(),
1264        ),
1265        ("token_usage_field_shape", "openai_compatible".to_string()),
1266    ] {
1267        let _ = store.provider_probes().upsert(NewProviderProbe {
1268            provider: provider.to_string(),
1269            model_id: model_id.to_string(),
1270            capability_key: key.to_string(),
1271            capability_value: value,
1272            confidence: "static".to_string(),
1273            error: None,
1274        });
1275    }
1276}
1277
1278pub fn parse_optional_json(raw: Option<&str>) -> Value {
1279    raw.and_then(|value| serde_json::from_str(value).ok())
1280        .unwrap_or(Value::Null)
1281}
1282
1283pub fn affected_paths_from_json(pending_action: &Value, changed_files: &Value) -> Vec<String> {
1284    let mut paths = Vec::new();
1285    collect_path_like_strings(changed_files, &mut paths);
1286    collect_path_like_strings(pending_action, &mut paths);
1287    paths.sort();
1288    paths.dedup();
1289    paths.truncate(25);
1290    paths
1291}
1292
1293fn collect_path_like_strings(value: &Value, paths: &mut Vec<String>) {
1294    match value {
1295        Value::String(value) if looks_path_like(value) => {
1296            paths.push(value.clone());
1297        },
1298        Value::Array(items) => {
1299            for item in items {
1300                collect_path_like_strings(item, paths);
1301            }
1302        },
1303        Value::Object(object) => {
1304            for (key, value) in object {
1305                match value {
1306                    Value::String(text)
1307                        if matches!(
1308                            key.as_str(),
1309                            "path"
1310                                | "file"
1311                                | "file_path"
1312                                | "target"
1313                                | "project_path"
1314                                | "snapshot_path"
1315                                | "cwd"
1316                        ) =>
1317                    {
1318                        if !text.trim().is_empty() {
1319                            paths.push(text.clone());
1320                        }
1321                    },
1322                    other => collect_path_like_strings(other, paths),
1323                }
1324            }
1325        },
1326        _ => {},
1327    }
1328}
1329
1330fn looks_path_like(value: &str) -> bool {
1331    let trimmed = value.trim();
1332    trimmed.starts_with('/')
1333        || trimmed.starts_with("./")
1334        || trimmed.starts_with("../")
1335        || trimmed.contains(std::path::MAIN_SEPARATOR)
1336}
1337
1338fn is_runtime_hygiene_checkpoint(checkpoint: &CheckpointRecord) -> bool {
1339    checkpoint.project_path.starts_with("/tmp/mermaid_")
1340}
1341
1342fn is_runtime_hygiene_approval(
1343    approval: &ApprovalRecord,
1344    test_checkpoint_ids: &HashSet<String>,
1345) -> bool {
1346    let is_restore_replay = approval.risk_classification == "restored_action"
1347        && approval.proposed_action.starts_with("restore replay:");
1348    if !is_restore_replay {
1349        return false;
1350    }
1351    match approval.checkpoint_id.as_deref() {
1352        Some(checkpoint_id) => test_checkpoint_ids.contains(checkpoint_id),
1353        None => true,
1354    }
1355}
1356
1357/// Whether a daemon-request failure happened *before* the request reached the
1358/// daemon — i.e. the Unix-socket `connect()` itself failed (daemon not running,
1359/// socket missing, or connection refused), so the action was never delivered
1360/// and is safe to re-run locally (RC-G/F25).
1361///
1362/// `request_daemon_text` wraps ONLY the `UnixStream::connect` error (with a
1363/// "failed to connect to …" context) and propagates post-connect write/read I/O
1364/// failures bare; a lost reply from a daemon that crashed *after* executing the
1365/// action surfaces as a JSON-parse error, never an I/O error. The connect-phase
1366/// `io::ErrorKind`s below (`NotFound`/`ConnectionRefused`/`PermissionDenied`)
1367/// are therefore unreachable from a post-send failure on this transport, so
1368/// matching them is a precise "never reached the daemon" signal. Everything else
1369/// — post-connect write/read I/O errors, empty/invalid-JSON replies, or a
1370/// daemon-level `ok:false` — is treated conservatively as "the action may have
1371/// already run". On platforms without Unix-socket IPC the transport bails before
1372/// connecting, so the request provably never reached a daemon → always pre-send.
1373fn daemon_failure_is_pre_send(err: &anyhow::Error) -> bool {
1374    #[cfg(not(unix))]
1375    {
1376        let _ = err;
1377        true
1378    }
1379    #[cfg(unix)]
1380    {
1381        match err.downcast_ref::<std::io::Error>() {
1382            Some(io) => matches!(
1383                io.kind(),
1384                std::io::ErrorKind::NotFound
1385                    | std::io::ErrorKind::ConnectionRefused
1386                    | std::io::ErrorKind::PermissionDenied
1387            ),
1388            None => false,
1389        }
1390    }
1391}
1392
1393/// Best-effort synchronous liveness check for a pid. The runtime client is sync,
1394/// so it can't reuse the async `process_running` in `providers::tool::exec`.
1395fn pid_alive(pid: u32) -> bool {
1396    if cfg!(windows) {
1397        Command::new("tasklist")
1398            .args(["/FI", &format!("PID eq {pid}"), "/NH"])
1399            .output()
1400            .map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
1401            .unwrap_or(false)
1402    } else {
1403        Command::new("kill")
1404            .args(["-0", &pid.to_string()])
1405            .status()
1406            .map(|s| s.success())
1407            .unwrap_or(false)
1408    }
1409}
1410
1411/// Validate a process "open" target before handing it to the OS opener (#63 —
1412/// defense-in-depth for a tampered local `processes` row). The target is either
1413/// a detected URL (a local dev server) or a log-file path mermaid wrote.
1414pub(crate) fn validate_open_target(target: &str) -> Result<()> {
1415    // Gate on the `://` authority so a bare path or Windows drive (`C:\…`, which
1416    // `Url::parse` would read as scheme "c") isn't misclassified as a URL.
1417    if target.contains("://") {
1418        let url = reqwest::Url::parse(target)
1419            .with_context(|| format!("refusing to open unparseable URL target: {target:?}"))?;
1420        anyhow::ensure!(
1421            matches!(url.scheme(), "http" | "https"),
1422            "refusing to open non-http(s) URL target: {target:?}"
1423        );
1424        // Dev-server URLs are loopback by design, so we deliberately do NOT apply
1425        // an SSRF host-class block here — the scheme allowlist is the control.
1426        return Ok(());
1427    }
1428    // Not a URL → a filesystem path (the process log). Open only an existing
1429    // regular file; this also rejects opaque `javascript:`/`data:` URIs, which
1430    // have no `://` and so fail the regular-file check.
1431    let meta = std::fs::metadata(target)
1432        .with_context(|| format!("refusing to open missing target: {target:?}"))?;
1433    anyhow::ensure!(
1434        meta.is_file(),
1435        "refusing to open non-regular-file target: {target:?}"
1436    );
1437    Ok(())
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442    use super::*;
1443    use std::path::PathBuf;
1444
1445    fn temp_db(name: &str) -> PathBuf {
1446        let dir = std::env::temp_dir().join(format!("mermaid_runtime_client_{name}"));
1447        let _ = std::fs::remove_dir_all(&dir);
1448        std::fs::create_dir_all(&dir).expect("create temp dir");
1449        dir.join("runtime.sqlite3")
1450    }
1451
1452    #[test]
1453    fn pid_alive_detects_live_and_dead() {
1454        // Our own process is alive.
1455        assert!(pid_alive(std::process::id()));
1456        // A reaped child is dead (#6 — the restart wait polls this).
1457        #[cfg(unix)]
1458        let mut child = Command::new("true").spawn().expect("spawn true");
1459        #[cfg(windows)]
1460        let mut child = Command::new("cmd")
1461            .args(["/C", "exit 0"])
1462            .spawn()
1463            .expect("spawn exit");
1464        let pid = child.id();
1465        let _ = child.wait();
1466        std::thread::sleep(std::time::Duration::from_millis(250));
1467        assert!(!pid_alive(pid));
1468    }
1469
1470    #[cfg(unix)]
1471    #[test]
1472    fn daemon_failure_pre_send_only_for_connect_kinds() {
1473        // F25: distinguish "never reached the daemon" (safe to re-run locally)
1474        // from "may have already run" (must not re-run a non-idempotent action).
1475        use anyhow::Context as _;
1476        use std::io::{Error as IoError, ErrorKind};
1477
1478        // Connect-phase failures — the request never left the socket. These are
1479        // wrapped exactly as `request_daemon_text` wraps its connect error.
1480        let refused: anyhow::Error = Err::<(), _>(IoError::from(ErrorKind::ConnectionRefused))
1481            .with_context(|| "failed to connect to /run/mermaidd.sock".to_string())
1482            .unwrap_err();
1483        assert!(daemon_failure_is_pre_send(&refused));
1484        let missing: anyhow::Error = Err::<(), _>(IoError::from(ErrorKind::NotFound))
1485            .with_context(|| "failed to connect to /run/mermaidd.sock".to_string())
1486            .unwrap_err();
1487        assert!(daemon_failure_is_pre_send(&missing));
1488
1489        // A lost reply from a daemon that crashed AFTER executing surfaces as a
1490        // JSON-parse error — ambiguous, may have run.
1491        assert!(!daemon_failure_is_pre_send(&anyhow::anyhow!(
1492            "daemon returned invalid JSON"
1493        )));
1494        // A post-connect write failure (broken pipe) is ambiguous — may have run.
1495        let broken = anyhow::Error::from(IoError::from(ErrorKind::BrokenPipe));
1496        assert!(!daemon_failure_is_pre_send(&broken));
1497        // A daemon-level `ok:false` error definitely reached the daemon.
1498        assert!(!daemon_failure_is_pre_send(&anyhow::anyhow!(
1499            "stop_process failed"
1500        )));
1501    }
1502
1503    #[test]
1504    fn validate_open_target_allows_http_rejects_file_and_js() {
1505        assert!(super::validate_open_target("http://localhost:3000").is_ok());
1506        assert!(super::validate_open_target("https://localhost:8443/app").is_ok());
1507        assert!(super::validate_open_target("file:///etc/passwd").is_err());
1508        assert!(super::validate_open_target("javascript:alert(1)").is_err());
1509        assert!(super::validate_open_target("data:text/html,<x>").is_err());
1510        assert!(super::validate_open_target("/no/such/path/xyz.log").is_err());
1511    }
1512
1513    #[test]
1514    fn restart_process_refuses_destructive_command() {
1515        let path = temp_db("restart_destructive");
1516        let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1517        let rec = service
1518            .store
1519            .processes()
1520            .upsert(NewProcess {
1521                id: Some("p-destruct".to_string()),
1522                task_id: None,
1523                pid: 0,
1524                command: "rm -rf /".to_string(),
1525                cwd: None,
1526                log_path: None,
1527                detected_url: None,
1528                status: ProcessStatus::Running,
1529                health: None,
1530            })
1531            .expect("seed process");
1532        // Refused before kill/spawn (pid 0 is never signaled).
1533        let err = service.restart_process(&rec.id).unwrap_err();
1534        assert!(err.to_string().contains("destructive"), "{err}");
1535        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1536    }
1537
1538    #[test]
1539    fn open_process_rejects_file_url_target() {
1540        let path = temp_db("open_file_url");
1541        let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1542        let rec = service
1543            .store
1544            .processes()
1545            .upsert(NewProcess {
1546                id: Some("p-open".to_string()),
1547                task_id: None,
1548                pid: 0,
1549                command: "true".to_string(),
1550                cwd: None,
1551                log_path: None,
1552                detected_url: Some("file:///etc/passwd".to_string()),
1553                status: ProcessStatus::Running,
1554                health: None,
1555            })
1556            .expect("seed process");
1557        // Errors before open_file — nothing is launched.
1558        assert!(service.open_process(&rec.id).is_err());
1559        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1560    }
1561
1562    #[test]
1563    fn local_client_lists_tasks_from_store() {
1564        let path = temp_db("tasks");
1565        let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1566        let task = service
1567            .store
1568            .tasks()
1569            .create(super::super::NewTask::new(
1570                "contract task",
1571                "/tmp/mermaid_runtime_client",
1572                "openai/test",
1573            ))
1574            .expect("task");
1575        let tasks = service.list_tasks(10).expect("list");
1576        assert_eq!(
1577            tasks.first().map(|item| item.id.as_str()),
1578            Some(task.id.as_str())
1579        );
1580        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1581    }
1582
1583    #[test]
1584    fn hygiene_preview_matches_test_artifacts_and_archive_is_idempotent() {
1585        let path = temp_db("hygiene");
1586        let store = RuntimeStore::open(&path).expect("open store");
1587        let checkpoint = store
1588            .checkpoints()
1589            .create(super::super::NewCheckpoint {
1590                id: Some("checkpoint-test".to_string()),
1591                task_id: None,
1592                project_path: "/tmp/mermaid_checkpoint_test".to_string(),
1593                snapshot_path: "/data/checkpoints/checkpoint-test".to_string(),
1594                changed_files_json: "[]".to_string(),
1595                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1596                approval_id: None,
1597                session_id: None,
1598                message_index: None,
1599            })
1600            .expect("create checkpoint");
1601        let approval = store
1602            .approvals()
1603            .create(super::super::NewApproval {
1604                task_id: None,
1605                proposed_action: "restore replay: write_file".to_string(),
1606                risk_classification: "restored_action".to_string(),
1607                policy_decision: "ask".to_string(),
1608                args_summary: None,
1609                checkpoint_id: Some(checkpoint.id.clone()),
1610                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1611            })
1612            .expect("create approval");
1613        store
1614            .checkpoints()
1615            .set_approval(&checkpoint.id, &approval.id)
1616            .expect("link approval");
1617
1618        let service = RuntimeService::from_store(store);
1619        let preview = service.hygiene_preview().expect("preview");
1620        assert_eq!(preview.counts.approvals, 1);
1621        assert_eq!(preview.counts.checkpoints, 1);
1622        let archived = service.hygiene_archive().expect("archive");
1623        assert_eq!(archived.archived.total, 2);
1624        let archived_again = service.hygiene_archive().expect("archive again");
1625        assert_eq!(archived_again.archived.total, 0);
1626        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1627    }
1628}