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}