Skip to main content

mermaid_cli/runtime_client/
client.rs

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