Skip to main content

orcs_runtime/components/
hil.rs

1//! Human-in-the-Loop (HIL) Component.
2//!
3//! Manages approval requests that require Human confirmation before execution.
4//!
5//! # Overview
6//!
7//! The HIL component sits between tool execution and the actual operation,
8//! ensuring Human approval for potentially dangerous or irreversible actions.
9//!
10//! ```text
11//! ToolsComponent                 HilComponent              Human
12//!       │                             │                      │
13//!       │ Request: need approval      │                      │
14//!       ├────────────────────────────►│                      │
15//!       │                             │ Display approval     │
16//!       │                             │ request              │
17//!       │                             ├─────────────────────►│
18//!       │                             │                      │
19//!       │                             │     Approve/Reject   │
20//!       │                             │◄─────────────────────┤
21//!       │    Response: approved/      │                      │
22//!       │◄────────────────────────────┤                      │
23//! ```
24//!
25//! # Example
26//!
27//! ```
28//! use orcs_runtime::components::{HilComponent, ApprovalRequest};
29//! use orcs_types::ComponentId;
30//!
31//! let mut hil = HilComponent::new();
32//!
33//! // Request approval for a file write operation
34//! let request = ApprovalRequest::new(
35//!     "write",
36//!     "Write to /etc/hosts",
37//!     serde_json::json!({ "path": "/etc/hosts" }),
38//! );
39//!
40//! let id = hil.submit(request);
41//! assert!(hil.is_pending(&id));
42//! ```
43
44use orcs_component::{
45    Component, ComponentError, ComponentSnapshot, EventCategory, SnapshotError, Status,
46};
47use orcs_event::{Request, Signal, SignalKind, SignalResponse};
48use orcs_types::ComponentId;
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51use std::collections::HashMap;
52
53/// Request for Human approval.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ApprovalRequest {
56    /// Unique ID for this approval request.
57    pub id: String,
58    /// Operation type (e.g., "write", "bash", "delete").
59    pub operation: String,
60    /// Human-readable description of what will happen.
61    pub description: String,
62    /// Additional context/payload.
63    pub context: Value,
64    /// Timestamp when the request was created.
65    pub created_at_ms: u64,
66}
67
68impl ApprovalRequest {
69    /// Creates a new approval request.
70    ///
71    /// The ID is automatically generated.
72    #[must_use]
73    pub fn new(
74        operation: impl Into<String>,
75        description: impl Into<String>,
76        context: Value,
77    ) -> Self {
78        Self {
79            id: uuid::Uuid::new_v4().to_string(),
80            operation: operation.into(),
81            description: description.into(),
82            context,
83            // Note: as_millis() returns u128, but realistically timestamps
84            // won't exceed u64::MAX until year 584 million. We saturate to
85            // u64::MAX as a safe fallback for theoretical overflow.
86            created_at_ms: std::time::SystemTime::now()
87                .duration_since(std::time::UNIX_EPOCH)
88                .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
89                .unwrap_or(0),
90        }
91    }
92
93    /// Creates an approval request with a specific ID.
94    #[must_use]
95    pub fn with_id(
96        id: impl Into<String>,
97        operation: impl Into<String>,
98        description: impl Into<String>,
99        context: Value,
100    ) -> Self {
101        Self {
102            id: id.into(),
103            operation: operation.into(),
104            description: description.into(),
105            context,
106            // See note in `new()` about timestamp handling
107            created_at_ms: std::time::SystemTime::now()
108                .duration_since(std::time::UNIX_EPOCH)
109                .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
110                .unwrap_or(0),
111        }
112    }
113}
114
115/// Result of an approval request.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub enum ApprovalResult {
118    /// Request was approved by Human.
119    Approved,
120    /// Request was rejected by Human.
121    Rejected {
122        /// Optional reason for rejection.
123        reason: Option<String>,
124    },
125    /// Request was approved with modifications by Human.
126    Modified {
127        /// The modified payload to use instead of original.
128        modified_payload: Value,
129    },
130}
131
132impl ApprovalResult {
133    /// Returns `true` if the result is `Approved` or `Modified`.
134    #[must_use]
135    pub fn is_approved(&self) -> bool {
136        matches!(self, Self::Approved | Self::Modified { .. })
137    }
138
139    /// Returns `true` if the result is `Rejected`.
140    #[must_use]
141    pub fn is_rejected(&self) -> bool {
142        matches!(self, Self::Rejected { .. })
143    }
144
145    /// Returns `true` if the result is `Modified`.
146    #[must_use]
147    pub fn is_modified(&self) -> bool {
148        matches!(self, Self::Modified { .. })
149    }
150
151    /// Returns the modified payload if this is a `Modified` result.
152    #[must_use]
153    pub fn modified_payload(&self) -> Option<&Value> {
154        match self {
155            Self::Modified { modified_payload } => Some(modified_payload),
156            _ => None,
157        }
158    }
159}
160
161/// Human-in-the-Loop Component.
162///
163/// Manages a queue of approval requests and responds to Human
164/// Approve/Reject signals.
165pub struct HilComponent {
166    id: ComponentId,
167    status: Status,
168    /// Pending approval requests by ID.
169    pending: HashMap<String, ApprovalRequest>,
170    /// Resolved requests (for audit trail).
171    resolved: HashMap<String, (ApprovalRequest, ApprovalResult)>,
172}
173
174impl HilComponent {
175    /// Creates a new HIL component.
176    #[must_use]
177    pub fn new() -> Self {
178        Self {
179            id: ComponentId::builtin("hil"),
180            status: Status::Idle,
181            pending: HashMap::new(),
182            resolved: HashMap::new(),
183        }
184    }
185
186    /// Submits a new approval request.
187    ///
188    /// Returns the request ID that can be used to check status or cancel.
189    pub fn submit(&mut self, request: ApprovalRequest) -> String {
190        let id = request.id.clone();
191        self.pending.insert(id.clone(), request);
192        id
193    }
194
195    /// Returns `true` if there are pending approval requests.
196    #[must_use]
197    pub fn has_pending(&self) -> bool {
198        !self.pending.is_empty()
199    }
200
201    /// Returns `true` if the given request ID is pending.
202    #[must_use]
203    pub fn is_pending(&self, id: &str) -> bool {
204        self.pending.contains_key(id)
205    }
206
207    /// Returns a reference to a pending request.
208    #[must_use]
209    pub fn get_pending(&self, id: &str) -> Option<&ApprovalRequest> {
210        self.pending.get(id)
211    }
212
213    /// Returns all pending requests.
214    #[must_use]
215    pub fn pending_requests(&self) -> Vec<&ApprovalRequest> {
216        self.pending.values().collect()
217    }
218
219    /// Returns the number of pending requests.
220    #[must_use]
221    pub fn pending_count(&self) -> usize {
222        self.pending.len()
223    }
224
225    /// Returns a resolved request by ID.
226    #[must_use]
227    pub fn get_resolved(&self, id: &str) -> Option<&(ApprovalRequest, ApprovalResult)> {
228        self.resolved.get(id)
229    }
230
231    /// Returns the number of resolved requests.
232    #[must_use]
233    pub fn resolved_count(&self) -> usize {
234        self.resolved.len()
235    }
236
237    /// Resolves an approval request.
238    ///
239    /// Returns `Ok(result)` if the request was found and resolved,
240    /// or `Err` if the request ID was not found.
241    pub fn resolve(
242        &mut self,
243        id: &str,
244        result: ApprovalResult,
245    ) -> Result<ApprovalResult, ComponentError> {
246        if let Some(request) = self.pending.remove(id) {
247            self.resolved
248                .insert(id.to_string(), (request, result.clone()));
249            Ok(result)
250        } else {
251            Err(ComponentError::ExecutionFailed(format!(
252                "Approval request not found: {}",
253                id
254            )))
255        }
256    }
257
258    /// Handles an Approve signal.
259    fn handle_approve(&mut self, approval_id: &str) -> SignalResponse {
260        match self.resolve(approval_id, ApprovalResult::Approved) {
261            Ok(_) => SignalResponse::Handled,
262            Err(_) => SignalResponse::Ignored,
263        }
264    }
265
266    /// Handles a Reject signal.
267    fn handle_reject(&mut self, approval_id: &str, reason: Option<String>) -> SignalResponse {
268        match self.resolve(approval_id, ApprovalResult::Rejected { reason }) {
269            Ok(_) => SignalResponse::Handled,
270            Err(_) => SignalResponse::Ignored,
271        }
272    }
273
274    /// Handles a Modify signal.
275    fn handle_modify(&mut self, approval_id: &str, modified_payload: Value) -> SignalResponse {
276        match self.resolve(approval_id, ApprovalResult::Modified { modified_payload }) {
277            Ok(_) => SignalResponse::Handled,
278            Err(_) => SignalResponse::Ignored,
279        }
280    }
281}
282
283impl Default for HilComponent {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289impl Component for HilComponent {
290    fn id(&self) -> &ComponentId {
291        &self.id
292    }
293
294    fn subscriptions(&self) -> &[EventCategory] {
295        &[EventCategory::Hil, EventCategory::Lifecycle]
296    }
297
298    fn status(&self) -> Status {
299        self.status
300    }
301
302    fn on_request(&mut self, request: &Request) -> Result<Value, ComponentError> {
303        match request.operation.as_str() {
304            "submit" => {
305                // Parse the approval request from payload
306                let approval_req: ApprovalRequest = serde_json::from_value(request.payload.clone())
307                    .map_err(|e| {
308                        ComponentError::InvalidPayload(format!("Invalid approval request: {}", e))
309                    })?;
310
311                let id = self.submit(approval_req);
312                Ok(serde_json::json!({ "approval_id": id, "status": "pending" }))
313            }
314            "status" => {
315                // Check status of a specific request
316                let id = request
317                    .payload
318                    .get("approval_id")
319                    .and_then(|v| v.as_str())
320                    .ok_or_else(|| ComponentError::InvalidPayload("Missing approval_id".into()))?;
321
322                if self.is_pending(id) {
323                    Ok(serde_json::json!({ "status": "pending" }))
324                } else if let Some((_, result)) = self.resolved.get(id) {
325                    Ok(serde_json::json!({ "status": "resolved", "result": result }))
326                } else {
327                    Err(ComponentError::ExecutionFailed(format!(
328                        "Approval request not found: {}",
329                        id
330                    )))
331                }
332            }
333            "list" => {
334                // List all pending requests
335                let pending: Vec<_> = self.pending_requests().into_iter().cloned().collect();
336                Ok(serde_json::json!({ "pending": pending }))
337            }
338            _ => Err(ComponentError::NotSupported(request.operation.clone())),
339        }
340    }
341
342    fn on_signal(&mut self, signal: &Signal) -> SignalResponse {
343        match &signal.kind {
344            SignalKind::Veto => {
345                self.abort();
346                SignalResponse::Abort
347            }
348            SignalKind::Approve { approval_id } => self.handle_approve(approval_id),
349            SignalKind::Reject {
350                approval_id,
351                reason,
352            } => self.handle_reject(approval_id, reason.clone()),
353            SignalKind::Modify {
354                approval_id,
355                modified_payload,
356            } => self.handle_modify(approval_id, modified_payload.clone()),
357            _ => SignalResponse::Ignored,
358        }
359    }
360
361    fn abort(&mut self) {
362        self.status = Status::Aborted;
363        // Reject all pending requests on abort
364        let pending_ids: Vec<_> = self.pending.keys().cloned().collect();
365        for id in pending_ids {
366            let _ = self.resolve(
367                &id,
368                ApprovalResult::Rejected {
369                    reason: Some("Aborted".into()),
370                },
371            );
372        }
373    }
374
375    fn snapshot(&self) -> Result<ComponentSnapshot, SnapshotError> {
376        let state = HilSnapshot::from_component(self);
377        ComponentSnapshot::from_state(self.id.fqn(), &state)
378    }
379
380    fn restore(&mut self, snapshot: &ComponentSnapshot) -> Result<(), SnapshotError> {
381        snapshot.validate(&self.id.fqn())?;
382        let state: HilSnapshot = snapshot.to_state()?;
383        state.apply_to(self);
384        Ok(())
385    }
386}
387
388/// Serializable snapshot of HilComponent state.
389///
390/// Only `resolved` entries are persisted (audit trail).
391/// `pending` entries are transient and not meaningful across sessions.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393struct HilSnapshot {
394    /// Resolved approval records (request + result pairs).
395    resolved: Vec<ResolvedRecord>,
396}
397
398/// A single resolved approval record for serialization.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400struct ResolvedRecord {
401    id: String,
402    request: ApprovalRequest,
403    result: ApprovalResult,
404}
405
406impl HilSnapshot {
407    fn from_component(hil: &HilComponent) -> Self {
408        let resolved = hil
409            .resolved
410            .iter()
411            .map(|(id, (req, result))| ResolvedRecord {
412                id: id.clone(),
413                request: req.clone(),
414                result: result.clone(),
415            })
416            .collect();
417        Self { resolved }
418    }
419
420    fn apply_to(&self, hil: &mut HilComponent) {
421        for record in &self.resolved {
422            hil.resolved.insert(
423                record.id.clone(),
424                (record.request.clone(), record.result.clone()),
425            );
426        }
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use orcs_component::EventCategory;
434    use orcs_types::{ChannelId, Principal, PrincipalId};
435
436    fn test_user() -> Principal {
437        Principal::User(PrincipalId::new())
438    }
439
440    #[test]
441    fn hil_component_creation() {
442        let hil = HilComponent::new();
443        assert_eq!(hil.id().name, "hil");
444        assert_eq!(hil.status(), Status::Idle);
445        assert!(!hil.has_pending());
446    }
447
448    #[test]
449    fn hil_submit_request() {
450        let mut hil = HilComponent::new();
451
452        let req = ApprovalRequest::new("write", "Write to file.txt", serde_json::json!({}));
453        let id = hil.submit(req);
454
455        assert!(hil.is_pending(&id));
456        assert!(hil.has_pending());
457        assert_eq!(hil.pending_count(), 1);
458    }
459
460    #[test]
461    fn hil_resolve_approve() {
462        let mut hil = HilComponent::new();
463
464        let req = ApprovalRequest::with_id(
465            "req-123",
466            "write",
467            "Write to file.txt",
468            serde_json::json!({}),
469        );
470        hil.submit(req);
471
472        let result = hil
473            .resolve("req-123", ApprovalResult::Approved)
474            .expect("resolve approve");
475        assert!(result.is_approved());
476        assert!(!hil.is_pending("req-123"));
477    }
478
479    #[test]
480    fn hil_resolve_reject() {
481        let mut hil = HilComponent::new();
482
483        let req = ApprovalRequest::with_id("req-456", "bash", "Run rm -rf", serde_json::json!({}));
484        hil.submit(req);
485
486        let result = hil.resolve(
487            "req-456",
488            ApprovalResult::Rejected {
489                reason: Some("Too dangerous".into()),
490            },
491        );
492        let result = result.expect("resolve reject");
493        assert!(result.is_rejected());
494    }
495
496    #[test]
497    fn hil_resolve_not_found() {
498        let mut hil = HilComponent::new();
499
500        let result = hil.resolve("nonexistent", ApprovalResult::Approved);
501        assert!(result.is_err());
502    }
503
504    #[test]
505    fn hil_signal_approve() {
506        let mut hil = HilComponent::new();
507
508        let req = ApprovalRequest::with_id("req-789", "write", "Write file", serde_json::json!({}));
509        hil.submit(req);
510
511        let signal = Signal::approve("req-789", test_user());
512        let response = hil.on_signal(&signal);
513
514        assert_eq!(response, SignalResponse::Handled);
515        assert!(!hil.is_pending("req-789"));
516    }
517
518    #[test]
519    fn hil_signal_reject() {
520        let mut hil = HilComponent::new();
521
522        let req = ApprovalRequest::with_id("req-abc", "bash", "Run command", serde_json::json!({}));
523        hil.submit(req);
524
525        let signal = Signal::reject("req-abc", Some("Not allowed".into()), test_user());
526        let response = hil.on_signal(&signal);
527
528        assert_eq!(response, SignalResponse::Handled);
529        assert!(!hil.is_pending("req-abc"));
530    }
531
532    #[test]
533    fn hil_signal_approve_not_found() {
534        let mut hil = HilComponent::new();
535
536        let signal = Signal::approve("nonexistent", test_user());
537        let response = hil.on_signal(&signal);
538
539        assert_eq!(response, SignalResponse::Ignored);
540    }
541
542    #[test]
543    fn hil_abort_rejects_all_pending() {
544        let mut hil = HilComponent::new();
545
546        hil.submit(ApprovalRequest::with_id(
547            "req-1",
548            "op1",
549            "desc1",
550            serde_json::json!({}),
551        ));
552        hil.submit(ApprovalRequest::with_id(
553            "req-2",
554            "op2",
555            "desc2",
556            serde_json::json!({}),
557        ));
558        assert_eq!(hil.pending_count(), 2);
559
560        hil.abort();
561
562        assert_eq!(hil.status(), Status::Aborted);
563        assert_eq!(hil.pending_count(), 0);
564    }
565
566    #[test]
567    fn hil_request_submit() {
568        let mut hil = HilComponent::new();
569        let source = ComponentId::builtin("tools");
570        let channel = ChannelId::new();
571
572        let payload = serde_json::json!({
573            "id": "custom-id",
574            "operation": "write",
575            "description": "Write to file",
576            "context": {},
577            "created_at_ms": 0
578        });
579
580        let req = Request::new(EventCategory::Hil, "submit", source, channel, payload);
581        let result = hil.on_request(&req);
582
583        let response = result.expect("submit request");
584        assert_eq!(response["status"], "pending");
585        assert!(hil.is_pending("custom-id"));
586    }
587
588    #[test]
589    fn hil_request_list() {
590        let mut hil = HilComponent::new();
591        hil.submit(ApprovalRequest::with_id(
592            "req-1",
593            "op1",
594            "desc1",
595            serde_json::json!({}),
596        ));
597        hil.submit(ApprovalRequest::with_id(
598            "req-2",
599            "op2",
600            "desc2",
601            serde_json::json!({}),
602        ));
603
604        let source = ComponentId::builtin("test");
605        let channel = ChannelId::new();
606        let req = Request::new(
607            EventCategory::Hil,
608            "list",
609            source,
610            channel,
611            serde_json::json!({}),
612        );
613
614        let response = hil.on_request(&req).expect("list request");
615        let pending = response["pending"]
616            .as_array()
617            .expect("pending should be array");
618        assert_eq!(pending.len(), 2);
619    }
620
621    #[test]
622    fn hil_request_status() {
623        let mut hil = HilComponent::new();
624        hil.submit(ApprovalRequest::with_id(
625            "check-me",
626            "op",
627            "desc",
628            serde_json::json!({}),
629        ));
630
631        let source = ComponentId::builtin("test");
632        let channel = ChannelId::new();
633        let req = Request::new(
634            EventCategory::Hil,
635            "status",
636            source,
637            channel,
638            serde_json::json!({ "approval_id": "check-me" }),
639        );
640
641        let response = hil.on_request(&req).expect("status request");
642        assert_eq!(response["status"], "pending");
643    }
644
645    #[test]
646    fn hil_signal_modify() {
647        let mut hil = HilComponent::new();
648
649        let req = ApprovalRequest::with_id("req-mod", "write", "Write file", serde_json::json!({}));
650        hil.submit(req);
651
652        let modified_payload = serde_json::json!({
653            "path": "/safe/path/file.txt",
654            "content": "modified content"
655        });
656        let signal = Signal::modify("req-mod", modified_payload.clone(), test_user());
657        let response = hil.on_signal(&signal);
658
659        assert_eq!(response, SignalResponse::Handled);
660        assert!(!hil.is_pending("req-mod"));
661
662        // Check resolved result
663        let (_, result) = hil.get_resolved("req-mod").expect("resolved req-mod");
664        assert!(result.is_modified());
665        assert!(result.is_approved()); // Modified counts as approved
666        assert_eq!(result.modified_payload(), Some(&modified_payload));
667    }
668
669    #[test]
670    fn hil_signal_modify_not_found() {
671        let mut hil = HilComponent::new();
672
673        let signal = Signal::modify("nonexistent", serde_json::json!({}), test_user());
674        let response = hil.on_signal(&signal);
675
676        assert_eq!(response, SignalResponse::Ignored);
677    }
678
679    #[test]
680    fn approval_result_helpers() {
681        let approved = ApprovalResult::Approved;
682        assert!(approved.is_approved());
683        assert!(!approved.is_rejected());
684        assert!(!approved.is_modified());
685        assert!(approved.modified_payload().is_none());
686
687        let rejected = ApprovalResult::Rejected {
688            reason: Some("test".into()),
689        };
690        assert!(!rejected.is_approved());
691        assert!(rejected.is_rejected());
692        assert!(!rejected.is_modified());
693
694        let modified = ApprovalResult::Modified {
695            modified_payload: serde_json::json!({"key": "value"}),
696        };
697        assert!(modified.is_approved()); // Modified counts as approved
698        assert!(!modified.is_rejected());
699        assert!(modified.is_modified());
700        assert!(modified.modified_payload().is_some());
701    }
702
703    // === Snapshot tests ===
704
705    #[test]
706    fn hil_snapshot_empty() {
707        let hil = HilComponent::new();
708        let snapshot = hil.snapshot().expect("snapshot empty hil");
709
710        assert_eq!(snapshot.component_fqn, hil.id().fqn());
711        assert!(!snapshot.is_empty());
712
713        let state: HilSnapshot = snapshot.to_state().expect("deserialize snapshot");
714        assert!(state.resolved.is_empty());
715    }
716
717    #[test]
718    fn hil_snapshot_with_resolved() {
719        let mut hil = HilComponent::new();
720
721        // Submit and resolve some requests
722        hil.submit(ApprovalRequest::with_id(
723            "req-1",
724            "write",
725            "Write file",
726            serde_json::json!({}),
727        ));
728        hil.resolve("req-1", ApprovalResult::Approved)
729            .expect("resolve req-1");
730
731        hil.submit(ApprovalRequest::with_id(
732            "req-2",
733            "bash",
734            "Run command",
735            serde_json::json!({}),
736        ));
737        hil.resolve(
738            "req-2",
739            ApprovalResult::Rejected {
740                reason: Some("dangerous".into()),
741            },
742        )
743        .expect("resolve req-2");
744
745        let snapshot = hil.snapshot().expect("snapshot with resolved");
746        let state: HilSnapshot = snapshot.to_state().expect("deserialize snapshot");
747        assert_eq!(state.resolved.len(), 2);
748    }
749
750    #[test]
751    fn hil_snapshot_roundtrip() {
752        let mut hil = HilComponent::new();
753
754        hil.submit(ApprovalRequest::with_id(
755            "req-1",
756            "write",
757            "Write file",
758            serde_json::json!({}),
759        ));
760        hil.resolve("req-1", ApprovalResult::Approved)
761            .expect("resolve req-1");
762
763        hil.submit(ApprovalRequest::with_id(
764            "req-2",
765            "bash",
766            "Run command",
767            serde_json::json!({}),
768        ));
769        hil.resolve(
770            "req-2",
771            ApprovalResult::Rejected {
772                reason: Some("nope".into()),
773            },
774        )
775        .expect("resolve req-2");
776
777        let snapshot = hil.snapshot().expect("snapshot for roundtrip");
778
779        // Restore into a fresh HilComponent
780        let mut hil2 = HilComponent::new();
781        assert_eq!(hil2.resolved_count(), 0);
782
783        hil2.restore(&snapshot).expect("restore snapshot");
784
785        assert_eq!(hil2.resolved_count(), 2);
786        let (_, result1) = hil2.get_resolved("req-1").expect("get restored req-1");
787        assert!(result1.is_approved());
788        let (_, result2) = hil2.get_resolved("req-2").expect("get restored req-2");
789        assert!(result2.is_rejected());
790    }
791
792    #[test]
793    fn hil_snapshot_pending_not_included() {
794        let mut hil = HilComponent::new();
795
796        // Submit but don't resolve
797        hil.submit(ApprovalRequest::with_id(
798            "pending-1",
799            "write",
800            "Write file",
801            serde_json::json!({}),
802        ));
803        assert!(hil.has_pending());
804
805        let snapshot = hil.snapshot().expect("snapshot with pending");
806        let state: HilSnapshot = snapshot.to_state().expect("deserialize snapshot");
807
808        // Pending should NOT be in snapshot
809        assert!(state.resolved.is_empty());
810    }
811
812    #[test]
813    fn hil_snapshot_fqn_mismatch() {
814        let hil = HilComponent::new();
815        let mut snapshot = hil.snapshot().expect("snapshot for mismatch test");
816        snapshot.component_fqn = "wrong::component".to_string();
817
818        let mut hil2 = HilComponent::new();
819        let err = hil2.restore(&snapshot);
820        assert!(err.is_err());
821    }
822}