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