Skip to main content

alien_commands/server/
command_registry.rs

1//! Command registry abstraction for command server
2//!
3//! The CommandRegistry is the **source of truth** for all command metadata.
4//! It tracks command state, timestamps, sizes, and errors.
5//!
6//! Implementations:
7//! - `InMemoryCommandRegistry`: In-memory implementation for tests and local dev (in this crate)
8//! - `PlatformCommandRegistry`: Platform API integration (in alien-manager)
9//!
10//! The command KV store holds only operational data: params/response blobs, pending indices, leases.
11
12use crate::error::Result;
13use alien_core::{CommandState, DeploymentModel};
14use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::sync::Arc;
19use tokio::sync::RwLock;
20use uuid::Uuid;
21
22/// Metadata returned when creating a command
23#[derive(Debug, Clone)]
24pub struct CommandMetadata {
25    /// Unique command ID
26    pub command_id: String,
27    /// How to deliver this command: platform push transport or pull leases.
28    ///
29    /// This field is currently encoded with `DeploymentModel` for compatibility,
30    /// but it is command delivery metadata, not the deployment reconciliation
31    /// model for the target environment.
32    pub deployment_model: DeploymentModel,
33    /// Project ID for routing/authorization
34    pub project_id: String,
35}
36
37/// Data needed to build an envelope during lease acquisition
38#[derive(Debug, Clone)]
39pub struct CommandEnvelopeData {
40    pub command_id: String,
41    pub deployment_id: String,
42    pub command: String, // command name
43    pub attempt: u32,
44    pub deadline: Option<DateTime<Utc>>,
45    pub state: CommandState,
46    /// Command delivery mode encoded with `DeploymentModel` for compatibility.
47    pub deployment_model: DeploymentModel,
48}
49
50/// Full status for GET /commands/{id}
51#[derive(Debug, Clone)]
52pub struct CommandStatus {
53    pub command_id: String,
54    pub deployment_id: String,
55    pub command: String, // command name
56    pub state: CommandState,
57    pub attempt: u32,
58    pub deadline: Option<DateTime<Utc>>,
59    pub created_at: DateTime<Utc>,
60    pub dispatched_at: Option<DateTime<Utc>>,
61    pub completed_at: Option<DateTime<Utc>>,
62    pub error: Option<serde_json::Value>,
63    pub request_size_bytes: Option<u64>,
64    pub response_size_bytes: Option<u64>,
65}
66
67/// Internal command record stored in memory
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70struct CommandRecord {
71    id: String,
72    deployment_id: String,
73    command: String,
74    state: CommandState,
75    attempt: u32,
76    deadline: Option<DateTime<Utc>>,
77    created_at: DateTime<Utc>,
78    dispatched_at: Option<DateTime<Utc>>,
79    completed_at: Option<DateTime<Utc>>,
80    request_size_bytes: Option<u64>,
81    response_size_bytes: Option<u64>,
82    error: Option<serde_json::Value>,
83    deployment_model: DeploymentModel,
84    project_id: String,
85}
86
87/// Abstraction for command metadata storage and lifecycle tracking.
88///
89/// The CommandRegistry is the source of truth for all command metadata.
90/// Implementations store command state, timestamps, and result information.
91#[async_trait]
92pub trait CommandRegistry: Send + Sync {
93    /// Create a new command and return metadata for routing.
94    ///
95    /// The registry generates the command_id, determines command delivery mode,
96    /// and stores all metadata (state, timestamps, etc.).
97    async fn create_command(
98        &self,
99        deployment_id: &str,
100        command_name: &str,
101        initial_state: CommandState,
102        deadline: Option<DateTime<Utc>>,
103        request_size_bytes: Option<u64>,
104    ) -> Result<CommandMetadata>;
105
106    /// Get metadata needed to build an envelope during lease acquisition.
107    ///
108    /// Returns None if command doesn't exist.
109    async fn get_command_metadata(&self, command_id: &str) -> Result<Option<CommandEnvelopeData>>;
110
111    /// Get full command status for status endpoint.
112    ///
113    /// Returns None if command doesn't exist.
114    async fn get_command_status(&self, command_id: &str) -> Result<Option<CommandStatus>>;
115
116    /// Update command state during lifecycle (dispatched, completed, failed).
117    async fn update_command_state(
118        &self,
119        command_id: &str,
120        state: CommandState,
121        dispatched_at: Option<DateTime<Utc>>,
122        completed_at: Option<DateTime<Utc>>,
123        response_size_bytes: Option<u64>,
124        error: Option<serde_json::Value>,
125    ) -> Result<()>;
126
127    /// Increment attempt count (on lease release/expiry).
128    ///
129    /// Returns the new attempt number.
130    async fn increment_attempt(&self, command_id: &str) -> Result<u32>;
131}
132
133/// In-memory implementation for tests and local development.
134///
135/// Tracks command metadata in memory. Configurable deployment model (defaults to Pull).
136pub struct InMemoryCommandRegistry {
137    commands: Arc<RwLock<HashMap<String, CommandRecord>>>,
138    deployment_model: DeploymentModel,
139}
140
141impl InMemoryCommandRegistry {
142    /// Create a new in-memory registry with Pull deployment model (default).
143    pub fn new() -> Self {
144        Self {
145            commands: Arc::new(RwLock::new(HashMap::new())),
146            deployment_model: DeploymentModel::Pull,
147        }
148    }
149
150    /// Create a new in-memory registry with the specified deployment model.
151    pub fn with_deployment_model(deployment_model: DeploymentModel) -> Self {
152        Self {
153            commands: Arc::new(RwLock::new(HashMap::new())),
154            deployment_model,
155        }
156    }
157
158    /// List all command IDs (useful for debugging/testing)
159    #[allow(dead_code)]
160    pub async fn list_command_ids(&self) -> Vec<String> {
161        let commands = self.commands.read().await;
162        commands.keys().cloned().collect()
163    }
164}
165
166impl Default for InMemoryCommandRegistry {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172#[async_trait]
173impl CommandRegistry for InMemoryCommandRegistry {
174    async fn create_command(
175        &self,
176        deployment_id: &str,
177        command_name: &str,
178        initial_state: CommandState,
179        deadline: Option<DateTime<Utc>>,
180        request_size_bytes: Option<u64>,
181    ) -> Result<CommandMetadata> {
182        let command_id = format!("cmd_{}", Uuid::new_v4());
183
184        let record = CommandRecord {
185            id: command_id.clone(),
186            deployment_id: deployment_id.to_string(),
187            command: command_name.to_string(),
188            state: initial_state,
189            attempt: 1,
190            deadline,
191            created_at: Utc::now(),
192            dispatched_at: None,
193            completed_at: None,
194            request_size_bytes,
195            response_size_bytes: None,
196            error: None,
197            deployment_model: self.deployment_model,
198            project_id: "local-dev".to_string(),
199        };
200
201        self.commands
202            .write()
203            .await
204            .insert(command_id.clone(), record);
205
206        Ok(CommandMetadata {
207            command_id,
208            deployment_model: self.deployment_model,
209            project_id: "local-dev".to_string(),
210        })
211    }
212
213    async fn get_command_metadata(&self, command_id: &str) -> Result<Option<CommandEnvelopeData>> {
214        let commands = self.commands.read().await;
215
216        Ok(commands.get(command_id).map(|r| CommandEnvelopeData {
217            command_id: r.id.clone(),
218            deployment_id: r.deployment_id.clone(),
219            command: r.command.clone(),
220            attempt: r.attempt,
221            deadline: r.deadline,
222            state: r.state,
223            deployment_model: r.deployment_model,
224        }))
225    }
226
227    async fn get_command_status(&self, command_id: &str) -> Result<Option<CommandStatus>> {
228        let commands = self.commands.read().await;
229
230        Ok(commands.get(command_id).map(|r| CommandStatus {
231            command_id: r.id.clone(),
232            deployment_id: r.deployment_id.clone(),
233            command: r.command.clone(),
234            state: r.state,
235            attempt: r.attempt,
236            deadline: r.deadline,
237            created_at: r.created_at,
238            dispatched_at: r.dispatched_at,
239            completed_at: r.completed_at,
240            error: r.error.clone(),
241            request_size_bytes: r.request_size_bytes,
242            response_size_bytes: r.response_size_bytes,
243        }))
244    }
245
246    async fn update_command_state(
247        &self,
248        command_id: &str,
249        state: CommandState,
250        dispatched_at: Option<DateTime<Utc>>,
251        completed_at: Option<DateTime<Utc>>,
252        response_size_bytes: Option<u64>,
253        error: Option<serde_json::Value>,
254    ) -> Result<()> {
255        let mut commands = self.commands.write().await;
256
257        if let Some(record) = commands.get_mut(command_id) {
258            record.state = state;
259
260            if let Some(ts) = dispatched_at {
261                record.dispatched_at = Some(ts);
262            }
263
264            if let Some(ts) = completed_at {
265                record.completed_at = Some(ts);
266            }
267
268            if let Some(size) = response_size_bytes {
269                record.response_size_bytes = Some(size);
270            }
271
272            if let Some(err) = error {
273                record.error = Some(err);
274            }
275        }
276
277        Ok(())
278    }
279
280    async fn increment_attempt(&self, command_id: &str) -> Result<u32> {
281        let mut commands = self.commands.write().await;
282
283        if let Some(record) = commands.get_mut(command_id) {
284            record.attempt += 1;
285            Ok(record.attempt)
286        } else {
287            Ok(1) // Default if not found
288        }
289    }
290}