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::{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/// A command target resolved by the registry, plus how commands reach it.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ResolvedCommandTarget {
26    /// The specific resource the command is addressed to
27    pub target: CommandTarget,
28    /// How commands are delivered to this target (Push or Pull)
29    pub delivery_mode: CommandDeliveryMode,
30}
31
32/// Metadata returned when creating a command
33#[derive(Debug, Clone)]
34pub struct CommandMetadata {
35    /// Unique command ID
36    pub command_id: String,
37    /// The specific resource the command is addressed to
38    pub target: CommandTarget,
39    /// How to dispatch the command (Push or Pull)
40    pub delivery_mode: CommandDeliveryMode,
41    /// Project ID for routing/authorization
42    pub project_id: String,
43}
44
45/// Data needed to build an envelope during lease acquisition
46#[derive(Debug, Clone)]
47pub struct CommandEnvelopeData {
48    pub command_id: String,
49    pub deployment_id: String,
50    pub command: String, // command name
51    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/// Full status for GET /commands/{id}
59#[derive(Debug, Clone)]
60pub struct CommandStatus {
61    pub command_id: String,
62    pub deployment_id: String,
63    pub command: String, // command name
64    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/// Internal command record stored in memory
77#[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
97/// Reject a command-target resource id that would break the `:`-delimited key
98/// grammar used by the pending index (`target:{dep}:{rid}:pending:…`) and the
99/// idempotency key (`{dep}:{rid}:{command}:{key}`).
100///
101/// An id containing `:` could forge or collide with another target's key
102/// segments, so it is rejected at the commands layer with a typed error. Only
103/// the delimiter that can forge index segments is enforced at this layer.
104pub 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
113/// Reject a command name that would break the same `:`-delimited idempotency
114/// key grammar (`{dep}:{rid}:{command}:{key}`).
115///
116/// The trailing client key routinely contains ':', so a ':' in the command
117/// name would be indistinguishable from the key boundary: (command="a:b",
118/// key="c") and (command="a", key="b:c") would forge the same key. Rejecting
119/// ':' in the command name keeps the command segment unambiguous — the twin of
120/// [`validate_command_target_id`] for the command segment.
121pub 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
130/// Target-selection rules shared by both the
131/// in-memory and SQLite registries route through.
132///
133/// - `requested = Some(id)`: the id must be well-formed (no `:`) and name an
134///   existing command-capable target, else `COMMAND_TARGET_NOT_FOUND`. An empty
135///   id never falls back to shorthand.
136/// - `requested = None` (single-target shorthand): exactly one target must
137///   exist, else `COMMAND_TARGET_AMBIGUOUS` (more than one) or
138///   `NO_COMMAND_TARGETS` (none).
139///
140/// The resolved target's own id is also validated, so a target registered with
141/// a `:`-bearing id can never resolve into the key grammar.
142pub 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            // Reject ids that would break the key grammar before any lookup.
150            validate_command_target_id(resource_id)?;
151            // An empty resource id is never a valid target — in particular it
152            // must NOT silently fall back to shorthand resolution.
153            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    // A registered target whose id breaks the key grammar must never resolve.
183    validate_command_target_id(&target.resource_id)?;
184    Ok(target)
185}
186
187/// Pinned per-type delivery rule — the single implementation both registries
188/// route through. Container and Daemon targets are always Pull; a Worker target
189/// follows `worker_mode`, the caller's derived worker context (production: Push
190/// only when the platform has a push path AND `stack_settings.deployment_model`
191/// is Push).
192pub 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/// Abstraction for command metadata storage and lifecycle tracking.
203///
204/// The CommandRegistry is the source of truth for all command metadata.
205/// Implementations store command state, timestamps, and result information.
206#[async_trait]
207pub trait CommandRegistry: Send + Sync {
208    /// Resolve which command-capable resource a command is addressed to.
209    ///
210    /// - `requested = Some(id)`: the target must exist and be command-capable,
211    ///   else `COMMAND_TARGET_NOT_FOUND` (an empty id never resolves).
212    /// - `requested = None` (single-target shorthand): exactly one
213    ///   command-capable target must exist, else `COMMAND_TARGET_AMBIGUOUS`
214    ///   (more than one) or `NO_COMMAND_TARGETS` (none).
215    ///
216    /// The returned delivery mode is derived from the target: Container and
217    /// Daemon targets are always Pull. Worker delivery is resolved from the
218    /// deployment model and platform: Kubernetes uses its in-cluster operator
219    /// relay, Local supports embedded Push and remote Pull, and cloud Workers
220    /// use their provider push path only for Push deployments.
221    async fn resolve_target(
222        &self,
223        deployment_id: &str,
224        requested: Option<&str>,
225    ) -> Result<ResolvedCommandTarget>;
226
227    /// Create a new command addressed to a previously resolved target and
228    /// return metadata for routing.
229    ///
230    /// The registry generates the command_id and stores all metadata
231    /// (state, target, timestamps, etc.).
232    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    /// Get metadata needed to build an envelope during lease acquisition.
243    ///
244    /// Returns None if command doesn't exist.
245    async fn get_command_metadata(&self, command_id: &str) -> Result<Option<CommandEnvelopeData>>;
246
247    /// Get full command status for status endpoint.
248    ///
249    /// Returns None if command doesn't exist.
250    async fn get_command_status(&self, command_id: &str) -> Result<Option<CommandStatus>>;
251
252    /// Atomically update a non-terminal command's lifecycle state.
253    ///
254    /// Returns `false` when the command became terminal before the update.
255    /// Terminal transitions use [`Self::complete_command`] instead.
256    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    /// Atomically transition a command from any NON-terminal state to the
267    /// given terminal `state`.
268    ///
269    /// Returns `false` when the command was already terminal — a concurrent
270    /// submitter won the race — so a terminal record can never be
271    /// overwritten by a late duplicate (redelivered execution racing the
272    /// original whose lease expired).
273    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    /// Atomically mark a command Dispatched unless it is already terminal.
283    ///
284    /// Returns `false` when the command reached a terminal state in the
285    /// meantime — e.g. a lease TTL-expired, a new poller won the takeover
286    /// put, and the ORIGINAL holder's submit landed between the poller's
287    /// terminal check and this write. An unconditional write there would
288    /// flip a committed terminal state back to Dispatched.
289    async fn mark_dispatched_if_not_terminal(
290        &self,
291        command_id: &str,
292        dispatched_at: DateTime<Utc>,
293    ) -> Result<bool>;
294
295    /// Increment attempt count (on lease release/expiry).
296    ///
297    /// Returns the new attempt number.
298    async fn increment_attempt(&self, command_id: &str) -> Result<u32>;
299}
300
301/// In-memory implementation for tests and local development.
302///
303/// Tracks command metadata in memory. Targets are registered explicitly via
304/// [`register_target`](Self::register_target); resolution then follows exactly
305/// the production rules documented on [`CommandRegistry::resolve_target`].
306pub struct InMemoryCommandRegistry {
307    commands: Arc<RwLock<HashMap<String, CommandRecord>>>,
308    /// Registered command-capable targets, in registration (declaration) order.
309    ///
310    /// Not scoped per deployment: this registry models a single local
311    /// deployment universe, so every deployment id resolves against the same
312    /// target set (the per-call resolution rules are identical to production).
313    targets: Arc<RwLock<Vec<CommandTarget>>>,
314    /// Delivery mode for Worker targets.
315    ///
316    /// In production this is derived from the deployment's platform and stack
317    /// settings (Push only when the platform has a push path AND
318    /// `stack_settings.deployment_model == Push`). The registrant supplies
319    /// that derived context here once, and the registry applies the pinned
320    /// per-type rule itself — so it is impossible to register a Container or
321    /// Daemon target with a Push mode that production could never produce.
322    worker_delivery_mode: CommandDeliveryMode,
323}
324
325impl InMemoryCommandRegistry {
326    /// Create a new in-memory registry whose Worker targets use Pull delivery
327    /// (the safe default: matches platforms without a push path).
328    pub fn new() -> Self {
329        Self::with_worker_delivery_mode(CommandDeliveryMode::Pull)
330    }
331
332    /// Create a new in-memory registry with the specified Worker delivery mode.
333    ///
334    /// Container/Daemon targets are always Pull regardless of this setting.
335    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    /// Register a command-capable target that commands can resolve to.
344    ///
345    /// Mirrors production, where the target set comes from the deployment's
346    /// stack (`Stack::command_targets()`: Worker/Container/Daemon resources
347    /// with `commands_enabled`). The id is validated through the same shared
348    /// guard as resolution, so a `:`-bearing id is rejected here at registration
349    /// rather than surfacing later as a key-grammar collision.
350    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    /// List all command IDs (useful for debugging/testing)
365    #[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) // Default if not found
560        }
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(&registry, 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(&registry, 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        // An explicitly requested empty resource id must never resolve (in
619        // particular it must NOT fall back to shorthand resolution).
620        let err = resolved(&registry, 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(&registry, 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(&registry, 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(&registry, 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        // Even with a Push-capable worker context, Container/Daemon are Pull.
668        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(&registry, Some("container-1")).await.unwrap();
680        assert_eq!(container.delivery_mode, CommandDeliveryMode::Pull);
681
682        let daemon = resolved(&registry, 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        // A manager-side pending path (e.g. Kubernetes operator relay, or a
701        // cloud stack whose deployment model is Pull).
702        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        // A requested id containing ':' is rejected with a typed error before
828        // any lookup — it can never be resolved into the key grammar.
829        let err = resolved(&registry, 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        // Even if a `:`-bearing target slips into the slice (e.g. an unvalidated
838        // upstream path), selecting it fails loudly rather than resolving.
839        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}