1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ApprovalRequest {
56 pub id: String,
58 pub operation: String,
60 pub description: String,
62 pub context: Value,
64 pub created_at_ms: u64,
66}
67
68impl ApprovalRequest {
69 #[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 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 #[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub enum ApprovalResult {
118 Approved,
120 Rejected {
122 reason: Option<String>,
124 },
125 Modified {
127 modified_payload: Value,
129 },
130}
131
132impl ApprovalResult {
133 #[must_use]
135 pub fn is_approved(&self) -> bool {
136 matches!(self, Self::Approved | Self::Modified { .. })
137 }
138
139 #[must_use]
141 pub fn is_rejected(&self) -> bool {
142 matches!(self, Self::Rejected { .. })
143 }
144
145 #[must_use]
147 pub fn is_modified(&self) -> bool {
148 matches!(self, Self::Modified { .. })
149 }
150
151 #[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
161pub struct HilComponent {
166 id: ComponentId,
167 status: Status,
168 pending: HashMap<String, ApprovalRequest>,
170 resolved: HashMap<String, (ApprovalRequest, ApprovalResult)>,
172}
173
174impl HilComponent {
175 #[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 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 #[must_use]
197 pub fn has_pending(&self) -> bool {
198 !self.pending.is_empty()
199 }
200
201 #[must_use]
203 pub fn is_pending(&self, id: &str) -> bool {
204 self.pending.contains_key(id)
205 }
206
207 #[must_use]
209 pub fn get_pending(&self, id: &str) -> Option<&ApprovalRequest> {
210 self.pending.get(id)
211 }
212
213 #[must_use]
215 pub fn pending_requests(&self) -> Vec<&ApprovalRequest> {
216 self.pending.values().collect()
217 }
218
219 #[must_use]
221 pub fn pending_count(&self) -> usize {
222 self.pending.len()
223 }
224
225 #[must_use]
227 pub fn get_resolved(&self, id: &str) -> Option<&(ApprovalRequest, ApprovalResult)> {
228 self.resolved.get(id)
229 }
230
231 #[must_use]
233 pub fn resolved_count(&self) -> usize {
234 self.resolved.len()
235 }
236
237 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
393struct HilSnapshot {
394 resolved: Vec<ResolvedRecord>,
396}
397
398#[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 let (_, result) = hil.get_resolved("req-mod").expect("resolved req-mod");
664 assert!(result.is_modified());
665 assert!(result.is_approved()); 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()); assert!(!modified.is_rejected());
699 assert!(modified.is_modified());
700 assert!(modified.modified_payload().is_some());
701 }
702
703 #[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 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 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 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 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}