1use crate::error::{ErrorData, Result};
13use alien_core::{CommandDeliveryMode, CommandState, CommandTarget, CommandTargetType};
14use alien_error::AlienError;
15use async_trait::async_trait;
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::sync::Arc;
20use tokio::sync::RwLock;
21use uuid::Uuid;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ResolvedCommandTarget {
26 pub target: CommandTarget,
28 pub delivery_mode: CommandDeliveryMode,
30}
31
32#[derive(Debug, Clone)]
34pub struct CommandMetadata {
35 pub command_id: String,
37 pub target: CommandTarget,
39 pub delivery_mode: CommandDeliveryMode,
41 pub project_id: String,
43}
44
45#[derive(Debug, Clone)]
47pub struct CommandEnvelopeData {
48 pub command_id: String,
49 pub deployment_id: String,
50 pub command: String, pub attempt: u32,
52 pub deadline: Option<DateTime<Utc>>,
53 pub state: CommandState,
54 pub target: CommandTarget,
55 pub delivery_mode: CommandDeliveryMode,
56}
57
58#[derive(Debug, Clone)]
60pub struct CommandStatus {
61 pub command_id: String,
62 pub deployment_id: String,
63 pub command: String, pub state: CommandState,
65 pub attempt: u32,
66 pub deadline: Option<DateTime<Utc>>,
67 pub created_at: DateTime<Utc>,
68 pub dispatched_at: Option<DateTime<Utc>>,
69 pub completed_at: Option<DateTime<Utc>>,
70 pub error: Option<serde_json::Value>,
71 pub request_size_bytes: Option<u64>,
72 pub response_size_bytes: Option<u64>,
73 pub target: CommandTarget,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79struct CommandRecord {
80 id: String,
81 deployment_id: String,
82 command: String,
83 state: CommandState,
84 attempt: u32,
85 deadline: Option<DateTime<Utc>>,
86 created_at: DateTime<Utc>,
87 dispatched_at: Option<DateTime<Utc>>,
88 completed_at: Option<DateTime<Utc>>,
89 request_size_bytes: Option<u64>,
90 response_size_bytes: Option<u64>,
91 error: Option<serde_json::Value>,
92 target: CommandTarget,
93 delivery_mode: CommandDeliveryMode,
94 project_id: String,
95}
96
97pub fn validate_command_target_id(resource_id: &str) -> Result<()> {
105 if resource_id.contains(':') {
106 return Err(AlienError::new(ErrorData::CommandTargetIdInvalid {
107 resource_id: resource_id.to_string(),
108 }));
109 }
110 Ok(())
111}
112
113pub fn validate_command_name(command: &str) -> Result<()> {
122 if command.contains(':') {
123 return Err(AlienError::new(ErrorData::InvalidCommand {
124 message: format!("Command name '{command}' must not contain ':'"),
125 }));
126 }
127 Ok(())
128}
129
130pub fn select_command_target(
143 deployment_id: &str,
144 targets: &[CommandTarget],
145 requested: Option<&str>,
146) -> Result<CommandTarget> {
147 let target = match requested {
148 Some(resource_id) => {
149 validate_command_target_id(resource_id)?;
151 let found = if resource_id.is_empty() {
154 None
155 } else {
156 targets.iter().find(|t| t.resource_id == resource_id)
157 };
158 found
159 .ok_or_else(|| {
160 AlienError::new(ErrorData::CommandTargetNotFound {
161 resource_id: resource_id.to_string(),
162 deployment_id: deployment_id.to_string(),
163 })
164 })?
165 .clone()
166 }
167 None => match targets {
168 [] => {
169 return Err(AlienError::new(ErrorData::NoCommandTargets {
170 deployment_id: deployment_id.to_string(),
171 }))
172 }
173 [single] => single.clone(),
174 _ => {
175 return Err(AlienError::new(ErrorData::CommandTargetAmbiguous {
176 deployment_id: deployment_id.to_string(),
177 }))
178 }
179 },
180 };
181
182 validate_command_target_id(&target.resource_id)?;
184 Ok(target)
185}
186
187pub fn delivery_mode_for(
193 resource_type: CommandTargetType,
194 worker_mode: CommandDeliveryMode,
195) -> CommandDeliveryMode {
196 match resource_type {
197 CommandTargetType::Container | CommandTargetType::Daemon => CommandDeliveryMode::Pull,
198 CommandTargetType::Worker => worker_mode,
199 }
200}
201
202#[async_trait]
207pub trait CommandRegistry: Send + Sync {
208 async fn resolve_target(
222 &self,
223 deployment_id: &str,
224 requested: Option<&str>,
225 ) -> Result<ResolvedCommandTarget>;
226
227 async fn create_command(
233 &self,
234 deployment_id: &str,
235 command_name: &str,
236 target: &ResolvedCommandTarget,
237 initial_state: CommandState,
238 deadline: Option<DateTime<Utc>>,
239 request_size_bytes: Option<u64>,
240 ) -> Result<CommandMetadata>;
241
242 async fn get_command_metadata(&self, command_id: &str) -> Result<Option<CommandEnvelopeData>>;
246
247 async fn get_command_status(&self, command_id: &str) -> Result<Option<CommandStatus>>;
251
252 async fn update_command_state(
257 &self,
258 command_id: &str,
259 state: CommandState,
260 dispatched_at: Option<DateTime<Utc>>,
261 completed_at: Option<DateTime<Utc>>,
262 response_size_bytes: Option<u64>,
263 error: Option<serde_json::Value>,
264 ) -> Result<bool>;
265
266 async fn complete_command(
274 &self,
275 command_id: &str,
276 state: CommandState,
277 completed_at: DateTime<Utc>,
278 response_size_bytes: Option<u64>,
279 error: Option<serde_json::Value>,
280 ) -> Result<bool>;
281
282 async fn mark_dispatched_if_not_terminal(
290 &self,
291 command_id: &str,
292 dispatched_at: DateTime<Utc>,
293 ) -> Result<bool>;
294
295 async fn increment_attempt(&self, command_id: &str) -> Result<u32>;
299}
300
301pub struct InMemoryCommandRegistry {
307 commands: Arc<RwLock<HashMap<String, CommandRecord>>>,
308 targets: Arc<RwLock<Vec<CommandTarget>>>,
314 worker_delivery_mode: CommandDeliveryMode,
323}
324
325impl InMemoryCommandRegistry {
326 pub fn new() -> Self {
329 Self::with_worker_delivery_mode(CommandDeliveryMode::Pull)
330 }
331
332 pub fn with_worker_delivery_mode(worker_delivery_mode: CommandDeliveryMode) -> Self {
336 Self {
337 commands: Arc::new(RwLock::new(HashMap::new())),
338 targets: Arc::new(RwLock::new(Vec::new())),
339 worker_delivery_mode,
340 }
341 }
342
343 pub async fn register_target(
351 &self,
352 resource_id: impl Into<String>,
353 resource_type: CommandTargetType,
354 ) -> Result<()> {
355 let resource_id = resource_id.into();
356 validate_command_target_id(&resource_id)?;
357 self.targets
358 .write()
359 .await
360 .push(CommandTarget::new(resource_id, resource_type));
361 Ok(())
362 }
363
364 #[allow(dead_code)]
366 pub async fn list_command_ids(&self) -> Vec<String> {
367 let commands = self.commands.read().await;
368 commands.keys().cloned().collect()
369 }
370}
371
372impl Default for InMemoryCommandRegistry {
373 fn default() -> Self {
374 Self::new()
375 }
376}
377
378#[async_trait]
379impl CommandRegistry for InMemoryCommandRegistry {
380 async fn resolve_target(
381 &self,
382 deployment_id: &str,
383 requested: Option<&str>,
384 ) -> Result<ResolvedCommandTarget> {
385 let targets = self.targets.read().await;
386 let target = select_command_target(deployment_id, &targets, requested)?;
387 let delivery_mode = delivery_mode_for(target.resource_type, self.worker_delivery_mode);
388 Ok(ResolvedCommandTarget {
389 target,
390 delivery_mode,
391 })
392 }
393
394 async fn create_command(
395 &self,
396 deployment_id: &str,
397 command_name: &str,
398 target: &ResolvedCommandTarget,
399 initial_state: CommandState,
400 deadline: Option<DateTime<Utc>>,
401 request_size_bytes: Option<u64>,
402 ) -> Result<CommandMetadata> {
403 let command_id = format!("cmd_{}", Uuid::new_v4());
404
405 let record = CommandRecord {
406 id: command_id.clone(),
407 deployment_id: deployment_id.to_string(),
408 command: command_name.to_string(),
409 state: initial_state,
410 attempt: 1,
411 deadline,
412 created_at: Utc::now(),
413 dispatched_at: None,
414 completed_at: None,
415 request_size_bytes,
416 response_size_bytes: None,
417 error: None,
418 target: target.target.clone(),
419 delivery_mode: target.delivery_mode,
420 project_id: "local-dev".to_string(),
421 };
422
423 self.commands
424 .write()
425 .await
426 .insert(command_id.clone(), record);
427
428 Ok(CommandMetadata {
429 command_id,
430 target: target.target.clone(),
431 delivery_mode: target.delivery_mode,
432 project_id: "local-dev".to_string(),
433 })
434 }
435
436 async fn get_command_metadata(&self, command_id: &str) -> Result<Option<CommandEnvelopeData>> {
437 let commands = self.commands.read().await;
438
439 Ok(commands.get(command_id).map(|r| CommandEnvelopeData {
440 command_id: r.id.clone(),
441 deployment_id: r.deployment_id.clone(),
442 command: r.command.clone(),
443 attempt: r.attempt,
444 deadline: r.deadline,
445 state: r.state,
446 target: r.target.clone(),
447 delivery_mode: r.delivery_mode,
448 }))
449 }
450
451 async fn get_command_status(&self, command_id: &str) -> Result<Option<CommandStatus>> {
452 let commands = self.commands.read().await;
453
454 Ok(commands.get(command_id).map(|r| CommandStatus {
455 command_id: r.id.clone(),
456 deployment_id: r.deployment_id.clone(),
457 command: r.command.clone(),
458 state: r.state,
459 attempt: r.attempt,
460 deadline: r.deadline,
461 created_at: r.created_at,
462 dispatched_at: r.dispatched_at,
463 completed_at: r.completed_at,
464 error: r.error.clone(),
465 request_size_bytes: r.request_size_bytes,
466 response_size_bytes: r.response_size_bytes,
467 target: r.target.clone(),
468 }))
469 }
470
471 async fn update_command_state(
472 &self,
473 command_id: &str,
474 state: CommandState,
475 dispatched_at: Option<DateTime<Utc>>,
476 completed_at: Option<DateTime<Utc>>,
477 response_size_bytes: Option<u64>,
478 error: Option<serde_json::Value>,
479 ) -> Result<bool> {
480 let mut commands = self.commands.write().await;
481
482 if let Some(record) = commands.get_mut(command_id) {
483 if record.state.is_terminal() {
484 return Ok(false);
485 }
486 record.state = state;
487
488 if let Some(ts) = dispatched_at {
489 record.dispatched_at = Some(ts);
490 }
491
492 if let Some(ts) = completed_at {
493 record.completed_at = Some(ts);
494 }
495
496 if let Some(size) = response_size_bytes {
497 record.response_size_bytes = Some(size);
498 }
499
500 if let Some(err) = error {
501 record.error = Some(err);
502 }
503 return Ok(true);
504 }
505
506 Ok(false)
507 }
508
509 async fn complete_command(
510 &self,
511 command_id: &str,
512 state: CommandState,
513 completed_at: DateTime<Utc>,
514 response_size_bytes: Option<u64>,
515 error: Option<serde_json::Value>,
516 ) -> Result<bool> {
517 let mut commands = self.commands.write().await;
518 let Some(record) = commands.get_mut(command_id) else {
519 return Ok(false);
520 };
521 if record.state.is_terminal() {
522 return Ok(false);
523 }
524 record.state = state;
525 record.completed_at = Some(completed_at);
526 if let Some(size) = response_size_bytes {
527 record.response_size_bytes = Some(size);
528 }
529 if let Some(err) = error {
530 record.error = Some(err);
531 }
532 Ok(true)
533 }
534
535 async fn mark_dispatched_if_not_terminal(
536 &self,
537 command_id: &str,
538 dispatched_at: DateTime<Utc>,
539 ) -> Result<bool> {
540 let mut commands = self.commands.write().await;
541 let Some(record) = commands.get_mut(command_id) else {
542 return Ok(false);
543 };
544 if record.state.is_terminal() {
545 return Ok(false);
546 }
547 record.state = CommandState::Dispatched;
548 record.dispatched_at = Some(dispatched_at);
549 Ok(true)
550 }
551
552 async fn increment_attempt(&self, command_id: &str) -> Result<u32> {
553 let mut commands = self.commands.write().await;
554
555 if let Some(record) = commands.get_mut(command_id) {
556 record.attempt += 1;
557 Ok(record.attempt)
558 } else {
559 Ok(1) }
561 }
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use alien_core::{CommandDeliveryMode, CommandTarget, CommandTargetType};
568
569 async fn resolved(
570 registry: &InMemoryCommandRegistry,
571 requested: Option<&str>,
572 ) -> Result<ResolvedCommandTarget> {
573 registry.resolve_target("dep-1", requested).await
574 }
575
576 #[tokio::test]
577 async fn test_resolve_explicit_target_found() {
578 let registry = InMemoryCommandRegistry::new();
579 registry
580 .register_target("worker-a", CommandTargetType::Worker)
581 .await
582 .unwrap();
583 registry
584 .register_target("daemon-b", CommandTargetType::Daemon)
585 .await
586 .unwrap();
587
588 let result = resolved(®istry, Some("daemon-b")).await.unwrap();
589 assert_eq!(
590 result.target,
591 CommandTarget::new("daemon-b", CommandTargetType::Daemon)
592 );
593 }
594
595 #[tokio::test]
596 async fn test_resolve_explicit_unknown_target_is_not_found() {
597 let registry = InMemoryCommandRegistry::new();
598 registry
599 .register_target("worker-a", CommandTargetType::Worker)
600 .await
601 .unwrap();
602
603 let err = resolved(®istry, Some("no-such-resource"))
604 .await
605 .unwrap_err();
606 assert_eq!(err.code, "COMMAND_TARGET_NOT_FOUND");
607 assert_eq!(err.http_status_code, Some(404));
608 }
609
610 #[tokio::test]
611 async fn test_resolve_explicit_empty_string_is_not_found() {
612 let registry = InMemoryCommandRegistry::new();
613 registry
614 .register_target("worker-a", CommandTargetType::Worker)
615 .await
616 .unwrap();
617
618 let err = resolved(®istry, Some("")).await.unwrap_err();
621 assert_eq!(err.code, "COMMAND_TARGET_NOT_FOUND");
622 }
623
624 #[tokio::test]
625 async fn test_resolve_shorthand_single_target_resolves() {
626 let registry = InMemoryCommandRegistry::new();
627 registry
628 .register_target("container-1", CommandTargetType::Container)
629 .await
630 .unwrap();
631
632 let result = resolved(®istry, None).await.unwrap();
633 assert_eq!(
634 result.target,
635 CommandTarget::new("container-1", CommandTargetType::Container)
636 );
637 }
638
639 #[tokio::test]
640 async fn test_resolve_shorthand_two_targets_is_ambiguous() {
641 let registry = InMemoryCommandRegistry::new();
642 registry
643 .register_target("worker-a", CommandTargetType::Worker)
644 .await
645 .unwrap();
646 registry
647 .register_target("worker-b", CommandTargetType::Worker)
648 .await
649 .unwrap();
650
651 let err = resolved(®istry, None).await.unwrap_err();
652 assert_eq!(err.code, "COMMAND_TARGET_AMBIGUOUS");
653 assert_eq!(err.http_status_code, Some(409));
654 }
655
656 #[tokio::test]
657 async fn test_resolve_shorthand_zero_targets_is_no_targets() {
658 let registry = InMemoryCommandRegistry::new();
659
660 let err = resolved(®istry, None).await.unwrap_err();
661 assert_eq!(err.code, "NO_COMMAND_TARGETS");
662 assert_eq!(err.http_status_code, Some(422));
663 }
664
665 #[tokio::test]
666 async fn test_delivery_mode_container_and_daemon_always_pull() {
667 let registry =
669 InMemoryCommandRegistry::with_worker_delivery_mode(CommandDeliveryMode::Push);
670 registry
671 .register_target("container-1", CommandTargetType::Container)
672 .await
673 .unwrap();
674 registry
675 .register_target("daemon-1", CommandTargetType::Daemon)
676 .await
677 .unwrap();
678
679 let container = resolved(®istry, Some("container-1")).await.unwrap();
680 assert_eq!(container.delivery_mode, CommandDeliveryMode::Pull);
681
682 let daemon = resolved(®istry, Some("daemon-1")).await.unwrap();
683 assert_eq!(daemon.delivery_mode, CommandDeliveryMode::Pull);
684 }
685
686 #[tokio::test]
687 async fn test_delivery_mode_worker_follows_registered_context() {
688 let push_registry =
689 InMemoryCommandRegistry::with_worker_delivery_mode(CommandDeliveryMode::Push);
690 push_registry
691 .register_target("worker-1", CommandTargetType::Worker)
692 .await
693 .unwrap();
694 let push_worker = push_registry
695 .resolve_target("dep-1", Some("worker-1"))
696 .await
697 .unwrap();
698 assert_eq!(push_worker.delivery_mode, CommandDeliveryMode::Push);
699
700 let pull_registry =
703 InMemoryCommandRegistry::with_worker_delivery_mode(CommandDeliveryMode::Pull);
704 pull_registry
705 .register_target("worker-1", CommandTargetType::Worker)
706 .await
707 .unwrap();
708 let pull_worker = pull_registry
709 .resolve_target("dep-1", Some("worker-1"))
710 .await
711 .unwrap();
712 assert_eq!(pull_worker.delivery_mode, CommandDeliveryMode::Pull);
713 }
714
715 #[tokio::test]
716 async fn test_create_command_stores_target_in_status_and_envelope_data() {
717 let registry = InMemoryCommandRegistry::new();
718 registry
719 .register_target("daemon-1", CommandTargetType::Daemon)
720 .await
721 .unwrap();
722
723 let resolved_target = registry.resolve_target("dep-1", None).await.unwrap();
724 let metadata = registry
725 .create_command(
726 "dep-1",
727 "sync-data",
728 &resolved_target,
729 CommandState::Pending,
730 None,
731 None,
732 )
733 .await
734 .unwrap();
735
736 let expected = CommandTarget::new("daemon-1", CommandTargetType::Daemon);
737 assert_eq!(metadata.target, expected);
738 assert_eq!(metadata.delivery_mode, CommandDeliveryMode::Pull);
739
740 let status = registry
741 .get_command_status(&metadata.command_id)
742 .await
743 .unwrap()
744 .unwrap();
745 assert_eq!(status.target, expected);
746
747 let envelope_data = registry
748 .get_command_metadata(&metadata.command_id)
749 .await
750 .unwrap()
751 .unwrap();
752 assert_eq!(envelope_data.target, expected);
753 assert_eq!(envelope_data.delivery_mode, CommandDeliveryMode::Pull);
754 }
755
756 #[tokio::test]
757 async fn non_terminal_update_cannot_resurrect_completed_command() {
758 let registry = InMemoryCommandRegistry::new();
759 registry
760 .register_target("daemon-1", CommandTargetType::Daemon)
761 .await
762 .unwrap();
763 let target = registry.resolve_target("dep-1", None).await.unwrap();
764 let command = registry
765 .create_command(
766 "dep-1",
767 "run",
768 &target,
769 CommandState::Dispatched,
770 None,
771 None,
772 )
773 .await
774 .unwrap();
775
776 assert!(registry
777 .complete_command(
778 &command.command_id,
779 CommandState::Succeeded,
780 Utc::now(),
781 None,
782 None,
783 )
784 .await
785 .unwrap());
786 assert!(!registry
787 .update_command_state(
788 &command.command_id,
789 CommandState::Pending,
790 None,
791 None,
792 None,
793 None,
794 )
795 .await
796 .unwrap());
797 assert_eq!(
798 registry
799 .get_command_status(&command.command_id)
800 .await
801 .unwrap()
802 .unwrap()
803 .state,
804 CommandState::Succeeded
805 );
806 }
807
808 #[tokio::test]
809 async fn test_register_target_rejects_colon_in_id() {
810 let registry = InMemoryCommandRegistry::new();
811 let err = registry
812 .register_target("evil:pending:x", CommandTargetType::Worker)
813 .await
814 .unwrap_err();
815 assert_eq!(err.code, "COMMAND_TARGET_ID_INVALID");
816 assert_eq!(err.http_status_code, Some(400));
817 }
818
819 #[tokio::test]
820 async fn test_resolve_explicit_colon_id_is_invalid() {
821 let registry = InMemoryCommandRegistry::new();
822 registry
823 .register_target("worker-a", CommandTargetType::Worker)
824 .await
825 .unwrap();
826
827 let err = resolved(®istry, Some("worker-a:pending:1"))
830 .await
831 .unwrap_err();
832 assert_eq!(err.code, "COMMAND_TARGET_ID_INVALID");
833 }
834
835 #[test]
836 fn test_select_command_target_rejects_registered_colon_id() {
837 let targets = vec![CommandTarget::new("a:pending:x", CommandTargetType::Worker)];
840 let err = select_command_target("dep-1", &targets, None).unwrap_err();
841 assert_eq!(err.code, "COMMAND_TARGET_ID_INVALID");
842 }
843}