Skip to main content

mermaid_cli/runtime/
client.rs

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