Skip to main content

alien_core/resources/
worker.rs

1use crate::error::{ErrorData, Result};
2use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
3use crate::{PublicEndpointOutput, WorkerPublicEndpoint};
4use alien_error::AlienError;
5use bon::Builder;
6use serde::{Deserialize, Serialize};
7use std::any::Any;
8use std::collections::HashMap;
9use std::fmt::Debug;
10
11/// Specifies the source of the worker's executable code.
12/// This can be a pre-built container image or source code that the system will build.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15#[serde(rename_all = "camelCase", tag = "type")]
16pub enum WorkerCode {
17    /// Container image.
18    #[serde(rename_all = "camelCase")]
19    Image {
20        /// Container image (e.g., `ghcr.io/myorg/myimage:latest`).
21        image: String,
22    },
23    /// Source code to be built.
24    #[serde(rename_all = "camelCase")]
25    Source {
26        /// The source directory to build from
27        src: String,
28        /// Toolchain configuration with type-safe options
29        toolchain: ToolchainConfig,
30    },
31}
32
33/// Configuration for different programming language toolchains.
34/// Each toolchain provides type-safe build configuration and auto-detection capabilities.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37#[serde(rename_all = "lowercase", tag = "type")]
38pub enum ToolchainConfig {
39    /// Rust with Cargo build system
40    #[serde(rename_all = "camelCase")]
41    Rust {
42        /// Name of the binary to build and run
43        binary_name: String,
44    },
45    /// TypeScript/JavaScript compiled to single executable with Bun
46    #[serde(rename_all = "camelCase")]
47    TypeScript {
48        /// Name of the compiled binary (defaults to package.json name if not specified)
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        binary_name: Option<String>,
51    },
52    /// Docker build from Dockerfile
53    #[serde(rename_all = "camelCase")]
54    Docker {
55        /// Dockerfile path relative to src (default: "Dockerfile")
56        #[serde(skip_serializing_if = "Option::is_none")]
57        dockerfile: Option<String>,
58        /// Build arguments for docker build
59        #[serde(skip_serializing_if = "Option::is_none")]
60        build_args: Option<HashMap<String, String>>,
61        /// Multi-stage build target
62        #[serde(skip_serializing_if = "Option::is_none")]
63        target: Option<String>,
64    },
65}
66
67/// Defines what triggers a worker execution.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70#[serde(tag = "type", rename_all = "camelCase")]
71pub enum WorkerTrigger {
72    /// Worker triggered by queue messages (always 1 message per invocation)
73    Queue {
74        /// Reference to the queue resource
75        queue: ResourceRef,
76    },
77    /// Worker triggered by storage events (object created, deleted, etc.)
78    Storage {
79        /// Reference to the storage resource
80        storage: ResourceRef,
81        /// Events to trigger on (e.g., ["created", "deleted"])
82        events: Vec<String>,
83    },
84    /// Worker triggered on a schedule (cron expression)
85    Schedule {
86        /// Cron expression for scheduling (standard 5-field unix cron)
87        cron: String,
88    },
89}
90
91impl WorkerTrigger {
92    /// Creates a queue trigger for the specified queue resource.
93    /// The worker will be automatically invoked when messages arrive in the queue.
94    /// Each message is processed individually (batch size of 1).
95    pub fn queue<R: ?Sized>(queue: &R) -> Self
96    where
97        for<'a> &'a R: Into<ResourceRef>,
98    {
99        let queue_ref: ResourceRef = queue.into();
100        WorkerTrigger::Queue { queue: queue_ref }
101    }
102
103    /// Creates a storage trigger for the specified storage resource.
104    /// The worker will be invoked when matching events occur on the storage resource.
105    pub fn storage<R: ?Sized>(storage: &R, events: Vec<String>) -> Self
106    where
107        for<'a> &'a R: Into<ResourceRef>,
108    {
109        let storage_ref: ResourceRef = storage.into();
110        WorkerTrigger::Storage {
111            storage: storage_ref,
112            events,
113        }
114    }
115
116    /// Creates a schedule trigger with the specified cron expression.
117    /// Uses standard 5-field unix cron format (minute hour day-of-month month day-of-week).
118    pub fn schedule<S: Into<String>>(cron: S) -> Self {
119        WorkerTrigger::Schedule { cron: cron.into() }
120    }
121}
122
123/// Represents a serverless worker that executes code in response to triggers or direct invocations.
124/// Workers are the primary compute resource in serverless applications, designed to be stateless and ephemeral.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
126#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
127#[serde(rename_all = "camelCase", deny_unknown_fields)]
128#[builder(start_fn = new)]
129pub struct Worker {
130    /// Identifier for the worker. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).
131    /// Maximum 64 characters.
132    #[builder(start_fn)]
133    pub id: String,
134
135    /// List of resource references this worker depends on.
136    // TODO: We need to verify that the same link isn't added multiple times.
137    #[builder(field)]
138    pub links: Vec<ResourceRef>,
139
140    /// List of triggers that define what events automatically invoke this worker.
141    /// If empty, the worker is only invokable directly via HTTP calls or platform-specific invocation APIs.
142    /// When configured, the worker will be automatically invoked when any of the specified trigger conditions are met.
143    #[builder(field)]
144    pub triggers: Vec<WorkerTrigger>,
145
146    /// Public endpoints exposed by this worker.
147    #[builder(field)]
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub public_endpoints: Vec<WorkerPublicEndpoint>,
150
151    /// Permission profile name that defines the permissions granted to this worker.
152    /// This references a profile defined in the stack's permission definitions.
153    pub permissions: String,
154
155    /// Code for the worker, either a pre-built image or source code to be built.
156    pub code: WorkerCode,
157
158    /// Memory allocated to the worker in megabytes (MB).
159    /// Default: 256
160    ///
161    /// Platform-specific constraints:
162    /// - **AWS Lambda**: 128–10240 MB in 1 MB increments
163    /// - **GCP Cloud Run**: 128–32768 MB
164    /// - **Azure Container Apps**: fixed CPU/memory pairs — 512, 1024, 1536, 2048, 2560,
165    ///   3072, 3584, 4096 MB. Values below 512 are automatically rounded up at deploy time.
166    #[builder(default = default_memory_mb())]
167    #[serde(default = "default_memory_mb")]
168    #[cfg_attr(feature = "openapi", schema(default = default_memory_mb))]
169    pub memory_mb: u32,
170
171    /// Maximum execution time for the worker in seconds.
172    /// Constraints: 1‑3600 seconds (platform-specific limits may apply)
173    /// Default: 30
174    #[builder(default = default_timeout_seconds())]
175    #[serde(default = "default_timeout_seconds")]
176    #[cfg_attr(feature = "openapi", schema(default = default_timeout_seconds))]
177    pub timeout_seconds: u32,
178
179    /// Key-value pairs to set as environment variables for the worker.
180    #[builder(default)]
181    #[serde(default)]
182    pub environment: HashMap<String, String>,
183
184    /// Whether the worker can receive remote commands via the Commands protocol.
185    /// When enabled, the runtime polls the manager for pending commands and executes registered handlers.
186    #[builder(default = default_commands_enabled())]
187    #[serde(default = "default_commands_enabled")]
188    #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
189    pub commands_enabled: bool,
190
191    /// Maximum number of concurrent executions allowed for the worker.
192    /// None means platform default applies.
193    pub concurrency_limit: Option<u32>,
194
195    /// Optional readiness probe configuration.
196    /// Only applicable for workers with Public ingress.
197    /// When configured, the probe will be executed after provisioning/update to verify the worker is ready.
198    pub readiness_probe: Option<ReadinessProbe>,
199}
200
201impl Worker {
202    /// The resource type identifier for Workers
203    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("worker");
204
205    /// Returns the permission profile name for this worker.
206    pub fn get_permissions(&self) -> &str {
207        &self.permissions
208    }
209
210    fn validate_public_endpoints(&self) -> Result<()> {
211        let mut endpoint_names = std::collections::HashSet::new();
212        for endpoint in &self.public_endpoints {
213            endpoint.validate_for_resource(&self.id)?;
214            if !endpoint_names.insert(endpoint.name.as_str()) {
215                return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
216                    resource_id: self.id.clone(),
217                    reason: format!("duplicate public endpoint name '{}'", endpoint.name),
218                }));
219            }
220        }
221
222        Ok(())
223    }
224}
225
226fn default_memory_mb() -> u32 {
227    256
228}
229
230fn default_timeout_seconds() -> u32 {
231    180
232}
233
234fn default_commands_enabled() -> bool {
235    false
236}
237
238/// HTTP method for readiness probe requests.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
241#[serde(rename_all = "UPPERCASE")]
242#[derive(Default)]
243pub enum HttpMethod {
244    #[default]
245    Get,
246    Post,
247    Put,
248    Delete,
249    Head,
250    Options,
251    Patch,
252}
253
254/// Configuration for HTTP-based readiness probe.
255/// This probe is executed after worker provisioning/update to verify the worker is ready to serve traffic.
256/// Only works with workers that have Public ingress.
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
259#[serde(rename_all = "camelCase")]
260pub struct ReadinessProbe {
261    /// HTTP method to use for the probe request.
262    /// Default: GET
263    #[serde(default)]
264    pub method: HttpMethod,
265
266    /// Path to request for the probe (e.g., "/health", "/ready").
267    /// Default: "/"
268    #[serde(default = "default_probe_path")]
269    pub path: String,
270}
271
272fn default_probe_path() -> String {
273    "/".to_string()
274}
275
276impl Default for ReadinessProbe {
277    fn default() -> Self {
278        Self {
279            method: HttpMethod::default(),
280            path: default_probe_path(),
281        }
282    }
283}
284
285use crate::resources::worker::worker_builder::State;
286
287impl<S: State> WorkerBuilder<S> {
288    /// Links the worker to another resource with specified permissions.
289    /// Accepts a reference to any type `R` where `&R` can be converted into `ResourceRef`.
290    pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
291    where
292        for<'a> &'a R: Into<ResourceRef>, // Use Higher-Rank Trait Bound (HRTB)
293    {
294        // Perform the conversion from &R to ResourceRef using .into()
295        let resource_ref: ResourceRef = resource.into();
296        self.links.push(resource_ref);
297        self
298    }
299
300    /// Adds a trigger to the worker. Workers can have multiple triggers.
301    /// Each trigger will independently invoke the worker when its conditions are met.
302    ///
303    /// # Examples
304    /// ```rust
305    /// # use alien_core::{Worker, WorkerTrigger, WorkerCode, Queue};
306    /// # let queue1 = Queue::new("queue-1".to_string()).build();
307    /// # let queue2 = Queue::new("queue-2".to_string()).build();
308    /// let worker = Worker::new("my-worker".to_string())
309    ///     .code(WorkerCode::Image { image: "test:latest".to_string() })
310    ///     .permissions("execution".to_string())
311    ///     .trigger(WorkerTrigger::queue(&queue1))
312    ///     .trigger(WorkerTrigger::queue(&queue2))
313    ///     .build();
314    /// ```
315    pub fn trigger(mut self, trigger: WorkerTrigger) -> Self {
316        self.triggers.push(trigger);
317        self
318    }
319
320    /// Exposes a named public endpoint for the worker.
321    pub fn public_endpoint(mut self, endpoint: WorkerPublicEndpoint) -> Self {
322        self.public_endpoints.push(endpoint);
323        self
324    }
325}
326
327// Implementation of ResourceDefinition trait for Worker
328impl ResourceDefinition for Worker {
329    fn get_resource_type(&self) -> ResourceType {
330        Self::RESOURCE_TYPE
331    }
332
333    fn id(&self) -> &str {
334        &self.id
335    }
336
337    fn get_dependencies(&self) -> Vec<ResourceRef> {
338        let mut dependencies = self.links.clone();
339
340        // Add trigger dependencies
341        for trigger in &self.triggers {
342            match trigger {
343                WorkerTrigger::Queue { queue } => {
344                    dependencies.push(queue.clone());
345                }
346                WorkerTrigger::Storage { storage, .. } => {
347                    dependencies.push(storage.clone());
348                }
349                WorkerTrigger::Schedule { .. } => {
350                    // Schedule triggers don't depend on other resources
351                }
352            }
353        }
354
355        dependencies
356    }
357
358    fn get_permissions(&self) -> Option<&str> {
359        Some(&self.permissions)
360    }
361
362    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
363        // Downcast to Worker type to use the existing validate_update method
364        let new_worker = new_config
365            .as_any()
366            .downcast_ref::<Worker>()
367            .ok_or_else(|| {
368                AlienError::new(ErrorData::UnexpectedResourceType {
369                    resource_id: self.id.clone(),
370                    expected: Self::RESOURCE_TYPE,
371                    actual: new_config.get_resource_type(),
372                })
373            })?;
374
375        if self.id != new_worker.id {
376            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
377                resource_id: self.id.clone(),
378                reason: "the 'id' field is immutable".to_string(),
379            }));
380        }
381        self.validate_public_endpoints()?;
382        new_worker.validate_public_endpoints()?;
383        if self.public_endpoints != new_worker.public_endpoints {
384            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
385                resource_id: self.id.clone(),
386                reason: "the 'publicEndpoints' field is immutable".to_string(),
387            }));
388        }
389        Ok(())
390    }
391
392    fn as_any(&self) -> &dyn Any {
393        self
394    }
395
396    fn as_any_mut(&mut self) -> &mut dyn Any {
397        self
398    }
399
400    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
401        Box::new(self.clone())
402    }
403
404    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
405        other.as_any().downcast_ref::<Worker>() == Some(self)
406    }
407
408    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
409        serde_json::to_value(self)
410    }
411}
412
413/// Outputs generated by a successfully provisioned Worker.
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
415#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
416#[serde(rename_all = "camelCase")]
417pub struct WorkerOutputs {
418    /// The platform-specific worker name.
419    pub worker_name: String,
420    /// Public endpoints resolved for this worker.
421    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
422    pub public_endpoints: HashMap<String, PublicEndpointOutput>,
423    /// The ARN or platform-specific identifier.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub identifier: Option<String>,
426    /// Push target for commands delivery. Platform-specific:
427    /// - AWS: Lambda function name or ARN
428    /// - GCP: Full Pub/Sub topic path (projects/{project}/topics/{topic})
429    /// - Azure: Service Bus "{namespace}/{queue}"
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub commands_push_target: Option<String>,
432}
433
434impl ResourceOutputsDefinition for WorkerOutputs {
435    fn get_resource_type(&self) -> ResourceType {
436        Worker::RESOURCE_TYPE.clone()
437    }
438
439    fn as_any(&self) -> &dyn Any {
440        self
441    }
442
443    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
444        Box::new(self.clone())
445    }
446
447    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
448        other.as_any().downcast_ref::<WorkerOutputs>() == Some(self)
449    }
450
451    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
452        serde_json::to_value(self)
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::Storage;
460
461    #[test]
462    fn test_worker_builder_direct_refs() {
463        let dummy_storage = Storage::new("test-storage".to_string()).build();
464        let dummy_storage_2 = Storage::new("test-storage-2".to_string()).build();
465
466        let worker = Worker::new("my-worker".to_string())
467            .code(WorkerCode::Image {
468                image: "test-image".to_string(),
469            })
470            .permissions("execution".to_string())
471            .link(&dummy_storage) // Pass reference directly
472            .link(&dummy_storage_2) // Add a second link
473            .build();
474
475        assert_eq!(worker.id, "my-worker");
476        assert_eq!(
477            worker.code,
478            WorkerCode::Image {
479                image: "test-image".to_string()
480            }
481        );
482
483        // Verify permissions was set correctly
484        assert_eq!(worker.permissions, "execution");
485
486        // Verify links were added correctly
487        assert!(worker
488            .links
489            .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
490        assert!(worker
491            .links
492            .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage-2")));
493        assert_eq!(worker.links.len(), 2); // Expect 2 links now
494    }
495
496    #[test]
497    fn test_worker_with_readiness_probe() {
498        let probe = ReadinessProbe {
499            method: HttpMethod::Post,
500            path: "/health".to_string(),
501        };
502
503        let worker = Worker::new("my-worker".to_string())
504            .code(WorkerCode::Image {
505                image: "test-image".to_string(),
506            })
507            .permissions("execution".to_string())
508            .public_endpoint(WorkerPublicEndpoint {
509                name: "api".to_string(),
510                host_label: None,
511                wildcard_subdomains: false,
512            })
513            .readiness_probe(probe.clone())
514            .build();
515
516        assert_eq!(worker.id, "my-worker");
517        assert_eq!(worker.public_endpoints[0].name, "api");
518        assert_eq!(worker.readiness_probe, Some(probe));
519    }
520
521    #[test]
522    fn test_readiness_probe_defaults() {
523        let probe = ReadinessProbe::default();
524        assert_eq!(probe.method, HttpMethod::Get);
525        assert_eq!(probe.path, "/");
526    }
527
528    #[test]
529    fn test_worker_with_rust_toolchain() {
530        let worker = Worker::new("my-rust-worker".to_string())
531            .code(WorkerCode::Source {
532                src: "./".to_string(),
533                toolchain: ToolchainConfig::Rust {
534                    binary_name: "my-app".to_string(),
535                },
536            })
537            .permissions("execution".to_string())
538            .build();
539
540        assert_eq!(worker.id, "my-rust-worker");
541
542        match &worker.code {
543            WorkerCode::Source { src, toolchain } => {
544                assert_eq!(src, "./");
545                assert_eq!(
546                    toolchain,
547                    &ToolchainConfig::Rust {
548                        binary_name: "my-app".to_string(),
549                    }
550                );
551            }
552            _ => panic!("Expected Source code"),
553        }
554    }
555
556    #[test]
557    fn test_worker_with_typescript_toolchain() {
558        let worker = Worker::new("my-ts-worker".to_string())
559            .code(WorkerCode::Source {
560                src: "./".to_string(),
561                toolchain: ToolchainConfig::TypeScript {
562                    binary_name: Some("my-ts-worker".to_string()),
563                },
564            })
565            .permissions("execution".to_string())
566            .build();
567
568        assert_eq!(worker.id, "my-ts-worker");
569
570        match &worker.code {
571            WorkerCode::Source { src, toolchain } => {
572                assert_eq!(src, "./");
573                assert_eq!(
574                    toolchain,
575                    &ToolchainConfig::TypeScript {
576                        binary_name: Some("my-ts-worker".to_string())
577                    }
578                );
579            }
580            _ => panic!("Expected Source code"),
581        }
582    }
583
584    #[test]
585    fn test_worker_with_queue_trigger() {
586        use crate::Queue;
587
588        let queue = Queue::new("test-queue".to_string()).build();
589
590        let worker = Worker::new("triggered-worker".to_string())
591            .code(WorkerCode::Image {
592                image: "test-image".to_string(),
593            })
594            .permissions("execution".to_string())
595            .trigger(WorkerTrigger::queue(&queue))
596            .build();
597
598        assert_eq!(worker.triggers.len(), 1);
599        if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
600            assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
601            assert_eq!(queue_ref.id, "test-queue");
602        } else {
603            panic!("Expected queue trigger");
604        }
605    }
606
607    #[test]
608    fn test_worker_trigger_dependencies() {
609        use crate::Queue;
610
611        let queue = Queue::new("test-queue".to_string()).build();
612        let storage = Storage::new("test-storage".to_string()).build();
613
614        let worker = Worker::new("triggered-worker".to_string())
615            .code(WorkerCode::Image {
616                image: "test-image".to_string(),
617            })
618            .permissions("execution".to_string())
619            .link(&storage) // regular link dependency
620            .trigger(WorkerTrigger::queue(&queue)) // trigger dependency
621            .build();
622
623        let dependencies = worker.get_dependencies();
624
625        // Should have both link and trigger dependencies
626        assert_eq!(dependencies.len(), 2);
627        assert!(dependencies.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
628        assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "test-queue")));
629    }
630
631    #[test]
632    fn test_worker_trigger_helper_methods() {
633        use crate::Queue;
634
635        let queue = Queue::new("my-queue".to_string()).build();
636
637        // Test the helper method
638        let trigger = WorkerTrigger::queue(&queue);
639
640        if let WorkerTrigger::Queue { queue: queue_ref } = trigger {
641            assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
642            assert_eq!(queue_ref.id, "my-queue");
643        } else {
644            panic!("Expected queue trigger");
645        }
646    }
647
648    #[test]
649    fn test_worker_with_multiple_triggers() {
650        use crate::Queue;
651
652        let queue1 = Queue::new("queue-1".to_string()).build();
653        let queue2 = Queue::new("queue-2".to_string()).build();
654
655        let worker = Worker::new("multi-triggered-worker".to_string())
656            .code(WorkerCode::Image {
657                image: "test-image".to_string(),
658            })
659            .permissions("execution".to_string())
660            .trigger(WorkerTrigger::queue(&queue1))
661            .trigger(WorkerTrigger::queue(&queue2))
662            .trigger(WorkerTrigger::schedule("0 * * * *".to_string()))
663            .build();
664
665        assert_eq!(worker.triggers.len(), 3);
666
667        // Check first queue trigger
668        if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
669            assert_eq!(queue_ref.id, "queue-1");
670        } else {
671            panic!("Expected first trigger to be queue-1");
672        }
673
674        // Check second queue trigger
675        if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[1] {
676            assert_eq!(queue_ref.id, "queue-2");
677        } else {
678            panic!("Expected second trigger to be queue-2");
679        }
680
681        // Check schedule trigger
682        if let WorkerTrigger::Schedule { cron } = &worker.triggers[2] {
683            assert_eq!(cron, "0 * * * *");
684        } else {
685            panic!("Expected third trigger to be schedule");
686        }
687
688        // Check dependencies include both queues
689        let dependencies = worker.get_dependencies();
690        assert_eq!(dependencies.len(), 2); // Only queues, schedule has no dependency
691        assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-1")));
692        assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-2")));
693    }
694
695    #[test]
696    fn test_worker_with_commands_enabled() {
697        let worker = Worker::new("cmd-worker".to_string())
698            .code(WorkerCode::Image {
699                image: "test-image".to_string(),
700            })
701            .permissions("execution".to_string())
702            .commands_enabled(true)
703            .build();
704
705        assert_eq!(worker.id, "cmd-worker");
706        assert!(worker.public_endpoints.is_empty());
707        assert_eq!(worker.commands_enabled, true);
708    }
709
710    #[test]
711    fn test_worker_defaults() {
712        let worker = Worker::new("default-worker".to_string())
713            .code(WorkerCode::Image {
714                image: "test-image".to_string(),
715            })
716            .permissions("execution".to_string())
717            .build();
718
719        // Test that defaults are applied correctly
720        assert!(worker.public_endpoints.is_empty());
721        assert_eq!(worker.commands_enabled, false);
722        assert_eq!(worker.memory_mb, 256);
723        assert_eq!(worker.timeout_seconds, 180);
724    }
725
726    #[test]
727    fn test_worker_public_ingress_with_commands() {
728        let worker = Worker::new("public-cmd-worker".to_string())
729            .code(WorkerCode::Image {
730                image: "test-image".to_string(),
731            })
732            .permissions("execution".to_string())
733            .public_endpoint(WorkerPublicEndpoint {
734                name: "api".to_string(),
735                host_label: None,
736                wildcard_subdomains: false,
737            })
738            .commands_enabled(true)
739            .build();
740
741        assert_eq!(worker.public_endpoints[0].name, "api");
742        assert_eq!(worker.commands_enabled, true);
743    }
744}