Skip to main content

mermaid_cli/runtime/
client.rs

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