1use af_context::{RunId, SubjectId, TenantId};
4use std::collections::{HashMap, VecDeque};
5use std::future::Future;
6use std::panic::AssertUnwindSafe;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use chrono::{DateTime, Utc};
11use futures::{stream::FuturesUnordered, FutureExt, StreamExt};
12use serde_json::Value;
13
14use crate::{
15 ActionIntent, CapabilityPin, ControlEpochs, LifecyclePolicy, NodeRegistry, SpecDriver,
16 WorkflowRevision,
17};
18
19#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
23pub struct Wakeup {
24 pub id: String,
26 pub kind: String,
28 #[serde(default)]
30 pub branch_id: Option<af_context::BranchId>,
31 pub payload: Value,
33}
34
35#[derive(Debug, Clone, PartialEq)]
37pub struct WorkItem {
38 pub id: String,
40 pub run_id: RunId,
42 pub tenant_id: TenantId,
44 pub subject_id: SubjectId,
46 pub spec_id: String,
48 pub definition_id: String,
50 pub workflow_revision: u64,
52 pub workflow_revision_digest: String,
54 pub execution_profile_id: String,
56 pub execution_profile_revision: u64,
58 pub execution_profile_digest: String,
60 pub kernel_abi_version: String,
62 pub capability_pins: Vec<CapabilityPin>,
64 pub lifecycle: LifecyclePolicy,
66 pub scheduled_at: DateTime<Utc>,
69 pub created_at: DateTime<Utc>,
71 pub claimed_at: DateTime<Utc>,
73 pub config: Value,
75 pub state_version: i64,
77 pub control_epochs: ControlEpochs,
79 pub cancel_requested: bool,
81 pub lease_version: i64,
83 #[doc = "Due timers and trigger deliveries, oldest first."]
84 pub wakeups: Vec<Wakeup>,
85}
86
87impl WorkItem {
88 pub fn is_converging(&self) -> bool {
90 self.cancel_requested
91 || self
92 .wakeups
93 .iter()
94 .any(|wakeup| wakeup.kind == "timer" && wakeup.payload["kind"] == "terminal_action")
95 }
96
97 fn validate_pins(&self) -> Result<(), String> {
98 for (name, value) in [
99 ("definition_id", self.definition_id.as_str()),
100 (
101 "workflow_revision_digest",
102 self.workflow_revision_digest.as_str(),
103 ),
104 ("execution_profile_id", self.execution_profile_id.as_str()),
105 (
106 "execution_profile_digest",
107 self.execution_profile_digest.as_str(),
108 ),
109 ("kernel_abi_version", self.kernel_abi_version.as_str()),
110 ] {
111 if value.trim().is_empty() {
112 return Err(format!("work item is missing pinned {name}"));
113 }
114 }
115 if self.workflow_revision == 0 || self.execution_profile_revision == 0 {
116 return Err("work item revision pins must be positive".into());
117 }
118 uuid::Uuid::parse_str(&self.run_id)
119 .map_err(|error| format!("work item has invalid run_id: {error}"))?;
120 if self.state_version < 0
121 || self.lease_version <= 0
122 || self.control_epochs.tenant < 0
123 || self.control_epochs.instance < 0
124 {
125 return Err("work item state, lease and control pins must be current".into());
126 }
127 Ok(())
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum WorkDisposition {
134 Continue {
136 delay_secs: i64,
138 },
139 Reschedule {
141 at: DateTime<Utc>,
143 },
144 Complete,
146 Failed {
148 error: String,
150 retry_at: Option<DateTime<Utc>>,
152 },
153}
154
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
157pub struct EvaluationOutcome {
158 pub triggered: bool,
160 pub matched: bool,
162 pub succeeded: bool,
164 pub action_terminal: bool,
166}
167
168#[derive(Debug, Clone, PartialEq)]
172pub struct WorkflowTransitionCommand {
173 pub delivery_key: String,
175 pub delivery_digest: String,
177 pub event_type: String,
179 pub event_digest: String,
181 pub event_payload: Value,
183 pub next_state: Value,
185 pub action_intents: Vec<ActionIntent>,
187 pub outcome: EvaluationOutcome,
189 pub consumed_wakeups: Vec<String>,
191 pub disposition: WorkDisposition,
193}
194
195impl WorkflowTransitionCommand {
196 pub fn failure(item: &WorkItem, error: impl Into<String>, retry_at: DateTime<Utc>) -> Self {
198 let error = error.into();
199 Self {
200 delivery_key: format!("supervisor:{}:{}", item.id, item.state_version),
201 delivery_digest: format!("failure:{}:{}", item.lease_version, error),
202 event_type: "workflow.evaluation_failed".into(),
203 event_digest: format!("failure:{}:{}", item.state_version, error),
204 event_payload: serde_json::json!({"error": error.clone()}),
205 next_state: item.config.clone(),
206 action_intents: Vec::new(),
207 outcome: EvaluationOutcome::default(),
208 consumed_wakeups: Vec::new(),
209 disposition: WorkDisposition::Failed {
210 error,
211 retry_at: Some(retry_at),
212 },
213 }
214 }
215
216 pub fn validate_for(&self, item: &WorkItem) -> Result<(), String> {
218 item.validate_pins()?;
219 for (name, value) in [
220 ("delivery_key", self.delivery_key.as_str()),
221 ("delivery_digest", self.delivery_digest.as_str()),
222 ("event_type", self.event_type.as_str()),
223 ("event_digest", self.event_digest.as_str()),
224 ] {
225 if value.trim().is_empty() {
226 return Err(format!("workflow command is missing {name}"));
227 }
228 }
229 if item.cancel_requested && !self.action_intents.is_empty() {
230 return Err("cancelled work cannot prepare external actions".into());
231 }
232 let consumed: std::collections::BTreeSet<_> = self.consumed_wakeups.iter().collect();
233 if consumed.len() != self.consumed_wakeups.len()
234 || consumed
235 .iter()
236 .any(|id| !item.wakeups.iter().any(|wakeup| &wakeup.id == *id))
237 || (!consumed.is_empty()
238 && matches!(self.disposition, WorkDisposition::Failed { .. })
239 && !self.outcome.triggered)
240 {
241 return Err("workflow command acknowledges unconsumed or unclaimed work".into());
242 }
243 for intent in &self.action_intents {
244 intent
245 .validate_prepared()
246 .map_err(|error| error.to_string())?;
247 if intent.tenant_id != item.tenant_id || intent.instance_id != item.id {
248 return Err("action intent escapes claimed work scope".into());
249 }
250 if intent.run_id != item.run_id {
251 return Err("action intent escapes claimed workflow run".into());
252 }
253 if intent.control_epochs.tenant != item.control_epochs.tenant
258 || intent.control_epochs.instance != item.control_epochs.instance
259 || intent.lease_epoch != item.lease_version
260 {
261 return Err("action intent uses stale control or lease pins".into());
262 }
263 if !item
264 .capability_pins
265 .iter()
266 .any(|pin| pin == &intent.capability)
267 {
268 return Err(format!(
269 "action capability '{}' is not pinned by the workflow revision",
270 intent.capability.id
271 ));
272 }
273 }
274 Ok(())
275 }
276}
277
278#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
280pub struct SupervisorStats {
281 pub claimed: usize,
283 pub failed: usize,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct SupervisorSettings {
290 pub worker_id: String,
292 pub lease_secs: i64,
294 pub requeue_delay_secs: i64,
296 pub claim_batch: i64,
298 pub concurrency: usize,
300}
301
302impl SupervisorSettings {
303 fn concurrency(&self) -> usize {
304 self.concurrency.max(1)
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
312pub enum SupervisorError {
313 #[error("work queue: {0}")]
315 Queue(String),
316 #[error("driver '{driver}': {reason}")]
318 Driver {
319 driver: String,
321 reason: String,
323 },
324 #[error("context: {0}")]
326 Context(String),
327 #[error("workflow state commit failed: {0}")]
329 Commit(String),
330}
331
332#[async_trait]
334pub trait WorkQueue: Send + Sync {
335 async fn claim_due(
337 &self,
338 spec_ids: &[String],
339 worker_id: &str,
340 lease_secs: i64,
341 batch: i64,
342 ) -> Result<Vec<WorkItem>, SupervisorError>;
343
344 async fn load_revision(&self, _item: &WorkItem) -> Result<WorkflowRevision, SupervisorError> {
347 Err(SupervisorError::Queue(
348 "published revision loading is unavailable".into(),
349 ))
350 }
351
352 async fn admit_evaluation(&self, _item: &WorkItem) -> Result<(), SupervisorError> {
356 Err(SupervisorError::Queue(
357 "workflow evaluation admission is unavailable".into(),
358 ))
359 }
360
361 async fn renew(
363 &self,
364 tenant_id: &str,
365 id: &str,
366 worker_id: &str,
367 lease_version: i64,
368 lease_secs: i64,
369 ) -> Result<(), SupervisorError>;
370 async fn commit_command(
372 &self,
373 item: &WorkItem,
374 command: &WorkflowTransitionCommand,
375 ) -> Result<(), SupervisorError>;
376}
377
378#[derive(Default)]
381pub struct MemoryWorkQueue {
382 items: std::sync::Mutex<Vec<WorkItem>>,
383 committed: std::sync::Mutex<Vec<(WorkItem, WorkflowTransitionCommand)>>,
384 renewals: std::sync::Mutex<Vec<(String, i64)>>,
385}
386
387impl MemoryWorkQueue {
388 pub fn new(items: Vec<WorkItem>) -> Self {
390 Self {
391 items: std::sync::Mutex::new(items),
392 ..Self::default()
393 }
394 }
395
396 pub fn push(&self, item: WorkItem) {
398 self.items
399 .lock()
400 .unwrap_or_else(std::sync::PoisonError::into_inner)
401 .push(item);
402 }
403
404 pub fn committed(&self) -> Vec<(WorkItem, WorkflowTransitionCommand)> {
406 self.committed
407 .lock()
408 .unwrap_or_else(std::sync::PoisonError::into_inner)
409 .clone()
410 }
411
412 pub fn renewals(&self) -> Vec<(String, i64)> {
414 self.renewals
415 .lock()
416 .unwrap_or_else(std::sync::PoisonError::into_inner)
417 .clone()
418 }
419}
420
421#[async_trait]
422impl WorkQueue for MemoryWorkQueue {
423 async fn admit_evaluation(&self, _: &WorkItem) -> Result<(), SupervisorError> {
424 Ok(())
425 }
426 async fn claim_due(
427 &self,
428 spec_ids: &[String],
429 _worker_id: &str,
430 _lease_secs: i64,
431 batch: i64,
432 ) -> Result<Vec<WorkItem>, SupervisorError> {
433 let mut items = self
434 .items
435 .lock()
436 .unwrap_or_else(std::sync::PoisonError::into_inner);
437 let mut claimed = Vec::new();
438 let mut index = 0;
439 while index < items.len() && claimed.len() < batch.max(0) as usize {
440 if spec_ids.is_empty() || spec_ids.contains(&items[index].spec_id) {
441 claimed.push(items.remove(index));
442 } else {
443 index += 1;
444 }
445 }
446 Ok(claimed)
447 }
448
449 async fn renew(
450 &self,
451 _tenant_id: &str,
452 id: &str,
453 _worker_id: &str,
454 lease_version: i64,
455 _lease_secs: i64,
456 ) -> Result<(), SupervisorError> {
457 self.renewals
458 .lock()
459 .unwrap_or_else(std::sync::PoisonError::into_inner)
460 .push((id.to_owned(), lease_version));
461 Ok(())
462 }
463
464 async fn commit_command(
465 &self,
466 item: &WorkItem,
467 command: &WorkflowTransitionCommand,
468 ) -> Result<(), SupervisorError> {
469 self.committed
470 .lock()
471 .unwrap_or_else(std::sync::PoisonError::into_inner)
472 .push((item.clone(), command.clone()));
473 Ok(())
474 }
475}
476
477#[async_trait]
479pub trait WorkflowDriver<Context>: Send + Sync
480where
481 Context: Send + Sync,
482{
483 fn name(&self) -> &'static str;
485 fn spec_ids(&self) -> Vec<&str>;
487 fn validate_specs(&self) -> Result<(), String>;
490 fn supports_revision(&self, _item: &WorkItem) -> bool {
493 false
494 }
495 async fn evaluate(
499 &self,
500 context: &Context,
501 item: &WorkItem,
502 ) -> Result<WorkflowTransitionCommand, String>;
503}
504
505pub struct DriverRegistry<Context: Send + Sync> {
507 drivers: Vec<Arc<dyn WorkflowDriver<Context>>>,
508 by_spec: HashMap<String, Arc<dyn WorkflowDriver<Context>>>,
509 nodes: Option<Arc<NodeRegistry>>,
510 startup_specs: Vec<String>,
511 compiled: tokio::sync::Mutex<VecDeque<CompiledRevision<Context>>>,
512}
513
514struct CompiledRevision<Context: Send + Sync> {
515 scope: Option<(TenantId, af_context::WorkflowDefinitionId)>,
516 key: (String, String),
517 content: Value,
518 driver: Arc<dyn WorkflowDriver<Context>>,
519}
520
521const COMPILED_REVISION_LIMIT: usize = 256;
522
523impl<Context: Send + Sync> Default for DriverRegistry<Context> {
524 fn default() -> Self {
525 Self {
526 drivers: Vec::new(),
527 by_spec: HashMap::new(),
528 nodes: None,
529 startup_specs: Vec::new(),
530 compiled: Default::default(),
531 }
532 }
533}
534
535impl<Context: Send + Sync> DriverRegistry<Context> {
536 pub fn new() -> Self {
538 Self::default()
539 }
540
541 pub fn with_node_registry(mut self, nodes: NodeRegistry) -> Self {
543 self.compiled.get_mut().clear();
544 self.startup_specs.clear();
545 self.nodes = Some(Arc::new(nodes));
546 self
547 }
548
549 pub fn prewarm_spec(&mut self, spec: &crate::Spec) -> Result<(), String> {
551 let nodes = self.nodes.as_ref().ok_or("node registry is unavailable")?;
552 let driver = SpecDriver::new(spec, nodes).map_err(|error| error.to_string())?;
553 let compiled = self.compiled.get_mut();
554 if compiled.len() == COMPILED_REVISION_LIMIT {
555 compiled.pop_front();
556 }
557 compiled.push_back(CompiledRevision {
558 scope: None,
559 key: (spec.spec_id.clone(), String::new()),
560 content: serde_json::to_value(spec).map_err(|error| error.to_string())?,
561 driver: Arc::new(driver),
562 });
563 if !self.startup_specs.contains(&spec.spec_id) {
564 self.startup_specs.push(spec.spec_id.clone());
565 }
566 Ok(())
567 }
568
569 pub async fn resolve_revision(
572 &self,
573 item: &WorkItem,
574 revision: WorkflowRevision,
575 ) -> Result<Arc<dyn WorkflowDriver<Context>>, String>
576 where
577 Context: 'static,
578 {
579 item.validate_pins()?;
580 revision.validate().map_err(|error| error.to_string())?;
581 if revision.definition_id != item.definition_id
582 || revision.revision != item.workflow_revision
583 || revision.content_digest != item.workflow_revision_digest
584 || revision.spec.spec_id != item.spec_id
585 || revision.kernel_abi_version != item.kernel_abi_version
586 || revision.capabilities != item.capability_pins
587 {
588 return Err("published revision does not match the claimed pins".into());
589 }
590 if !matches!(revision.kernel_abi_version.as_str(), "1" | "kernel-abi-1") {
591 return Err("unsupported workflow kernel ABI".into());
592 }
593 let nodes = self
594 .nodes
595 .as_ref()
596 .ok_or("dynamic spec execution is unavailable")?;
597 let builtins = NodeRegistry::with_builtins();
598 for (expression, version) in &revision.expression_versions {
599 let supported = nodes.capability_manifests().any(|manifest| {
600 manifest.id == *expression && manifest.contract_version == *version
601 }) || (builtins.is_step(expression) && version == "1");
602 if !supported {
603 return Err("unsupported expression version".into());
604 }
605 }
606 for pin in &revision.capabilities {
607 let manifest = nodes
608 .capability_by_pin(pin)
609 .ok_or("pinned capability is unavailable")?;
610 debug_assert_eq!(manifest.id, pin.id);
611 }
612 let selected_nodes = nodes
613 .for_capability_pins(&revision.capabilities)
614 .map_err(|error| error.to_string())?;
615 for node in revision
616 .spec
617 .branches
618 .iter()
619 .flat_map(|branch| &branch.nodes)
620 {
621 if let Some(manifest) = selected_nodes.capability(&node.node_type) {
622 if !revision.capabilities.iter().any(|pin| {
623 pin.id == manifest.id
624 && pin.contract_version == manifest.contract_version
625 && pin.content_digest == manifest.content_digest
626 }) {
627 return Err("graph capability lacks an exact revision pin".into());
628 }
629 } else if nodes
630 .capability_manifests()
631 .any(|manifest| manifest.id == node.node_type)
632 {
633 return Err("graph capability lacks an exact revision pin".into());
634 } else if !builtins.is_step(&node.node_type) && !builtins.is_ingress(&node.node_type) {
635 return Err("product graph node requires a capability manifest".into());
636 }
637 }
638 let content = serde_json::json!({
639 "spec": revision.spec, "abi": revision.kernel_abi_version,
640 "capabilities": revision.capabilities,
641 "expressions": revision.expression_versions,
642 "dependencies": revision.dependency_set_digest,
643 });
644 let key = (item.spec_id.clone(), item.workflow_revision_digest.clone());
645 let scope = Some((
646 item.tenant_id.clone(),
647 item.definition_id
648 .parse()
649 .map_err(|_| "invalid workflow definition identity")?,
650 ));
651 let mut compiled = self.compiled.lock().await;
652 if let Some(entry) = compiled
653 .iter()
654 .find(|entry| entry.key == key && entry.scope == scope)
655 {
656 if entry.content != content {
657 return Err("revision digest refers to different compiled content".into());
658 }
659 return Ok(entry.driver.clone());
660 }
661 if let Some(index) = compiled.iter().position(|entry| {
662 entry.key.0 == item.spec_id
663 && entry.key.1.is_empty()
664 && entry.content == content["spec"]
665 }) {
666 let mut entry = compiled.remove(index).expect("located cache entry exists");
667 entry.scope = scope;
668 entry.key = key;
669 entry.content = content;
670 let driver = entry.driver.clone();
671 compiled.push_back(entry);
672 return Ok(driver);
673 }
674 let nodes = selected_nodes;
675 let driver: Arc<dyn WorkflowDriver<Context>> = Arc::new(
678 tokio::task::spawn_blocking(move || {
679 SpecDriver::new(&revision.spec, &nodes).map_err(|error| error.to_string())
680 })
681 .await
682 .map_err(|_| "spec compiler panicked")??,
683 );
684 if compiled.len() == COMPILED_REVISION_LIMIT {
685 compiled.pop_front();
686 }
687 compiled.push_back(CompiledRevision {
688 scope,
689 key,
690 content,
691 driver: driver.clone(),
692 });
693 Ok(driver)
694 }
695
696 async fn resolve<Queue: WorkQueue>(
697 &self,
698 queue: &Queue,
699 item: &WorkItem,
700 ) -> Result<Arc<dyn WorkflowDriver<Context>>, String>
701 where
702 Context: 'static,
703 {
704 if let Some(driver) = self.by_spec.get(&item.spec_id) {
705 if !driver.supports_revision(item) {
706 return Err("custom driver does not support the claimed revision".into());
707 }
708 return Ok(driver.clone());
709 }
710 let revision = queue
711 .load_revision(item)
712 .await
713 .map_err(|error| error.to_string())?;
714 self.resolve_revision(item, revision).await
715 }
716
717 pub fn register(
720 &mut self,
721 driver: Arc<dyn WorkflowDriver<Context>>,
722 ) -> Result<&mut Self, SupervisorError> {
723 for spec_id in driver.spec_ids() {
724 if let Some(existing) = self.by_spec.get(spec_id) {
725 return Err(SupervisorError::Driver {
726 driver: driver.name().to_owned(),
727 reason: format!(
728 "spec '{spec_id}' is already claimed by '{}'",
729 existing.name()
730 ),
731 });
732 }
733 self.by_spec.insert(spec_id.to_owned(), driver.clone());
734 }
735 self.drivers.push(driver);
736 Ok(self)
737 }
738
739 pub fn spec_ids(&self) -> Vec<String> {
741 let mut ids = self.by_spec.keys().cloned().collect::<Vec<_>>();
742 ids.extend(self.startup_specs.iter().cloned());
743 ids.sort();
744 ids.dedup();
745 ids
746 }
747
748 pub fn for_spec(&self, spec_id: &str) -> Option<&Arc<dyn WorkflowDriver<Context>>> {
750 self.by_spec.get(spec_id)
751 }
752
753 pub fn names(&self) -> Vec<&'static str> {
755 self.drivers.iter().map(|driver| driver.name()).collect()
756 }
757
758 pub fn is_empty(&self) -> bool {
760 self.drivers.is_empty() && self.startup_specs.is_empty()
761 }
762
763 pub fn validate_all(&self) -> Result<(), SupervisorError> {
765 for driver in &self.drivers {
766 driver
767 .validate_specs()
768 .map_err(|reason| SupervisorError::Driver {
769 driver: driver.name().to_owned(),
770 reason,
771 })?;
772 }
773 Ok(())
774 }
775}
776
777pub async fn run_due_pass<Context, Queue, BuildContext, BuildFuture>(
780 queue: &Queue,
781 registry: &DriverRegistry<Context>,
782 settings: &SupervisorSettings,
783 build_context: BuildContext,
784) -> Result<SupervisorStats, SupervisorError>
785where
786 Context: Send + Sync + 'static,
787 Queue: WorkQueue,
788 BuildContext: FnOnce() -> BuildFuture,
789 BuildFuture: Future<Output = Result<Context, String>> + Send,
790{
791 if registry.is_empty() && registry.nodes.is_none() {
792 return Ok(SupervisorStats::default());
793 }
794 let claim_limit = settings
795 .claim_batch
796 .clamp(1, i64::try_from(settings.concurrency()).unwrap_or(i64::MAX));
797 let items = queue
798 .claim_due(
799 &if registry.nodes.is_some() {
800 Vec::new()
801 } else {
802 registry.spec_ids()
803 },
804 &settings.worker_id,
805 settings.lease_secs,
806 claim_limit,
807 )
808 .await?;
809 if items.is_empty() {
810 return Ok(SupervisorStats::default());
811 }
812
813 let context = match build_context().await {
814 Ok(context) => Arc::new(context),
815 Err(error) => {
816 let mut cleanup_errors = Vec::new();
817 for item in &items {
818 let command = WorkflowTransitionCommand::failure(
819 item,
820 error.clone(),
821 Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
822 );
823 if let Err(cleanup) = queue.commit_command(item, &command).await {
824 cleanup_errors.push(format!("{} commit failure: {cleanup}", item.id));
825 }
826 }
827 if !cleanup_errors.is_empty() {
828 return Err(SupervisorError::Context(format!(
829 "{error}; claimed work cleanup failed: {}",
830 cleanup_errors.join(", ")
831 )));
832 }
833 return Err(SupervisorError::Context(error));
834 }
835 };
836
837 let claimed = items.len();
838 let mut failed = 0;
839 let mut persistence_errors = Vec::new();
840 let mut work = items.into_iter();
841 let mut tasks = FuturesUnordered::new();
842
843 let spawn_next = |tasks: &mut FuturesUnordered<_>, work: &mut std::vec::IntoIter<WorkItem>| {
844 let Some(item) = work.next() else {
845 return false;
846 };
847 let context = context.clone();
848 let renewal_period = std::time::Duration::from_millis(
849 (settings.lease_secs.clamp(1, 3600) as u64 * 1000 / 3).max(1),
850 );
851 tasks.push(async move {
852 let evaluation_item = item.clone();
853 let evaluation = async {
854 evaluation_item.validate_pins()?;
855 if !evaluation_item.is_converging() {
856 queue
857 .admit_evaluation(&evaluation_item)
858 .await
859 .map_err(|error| error.to_string())?;
860 }
861 let driver = registry.resolve(queue, &evaluation_item).await?;
862 AssertUnwindSafe(driver.evaluate(&context, &evaluation_item))
863 .catch_unwind()
864 .await
865 .map_err(|_| "driver panicked".to_string())
866 .and_then(|result| result)
867 };
868 tokio::pin!(evaluation);
869 let mut renewal = tokio::time::interval_at(
870 tokio::time::Instant::now() + renewal_period,
871 renewal_period,
872 );
873 renewal.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
874 let result = loop {
875 tokio::select! {
876 result = &mut evaluation => break result,
877 _ = renewal.tick() => {
878 if let Err(error) = queue
879 .renew(
880 &item.tenant_id,
881 &item.id,
882 &settings.worker_id,
883 item.lease_version,
884 settings.lease_secs,
885 )
886 .await
887 {
888 break Err(format!("lease renewal failed: {error}"));
889 }
890 }
891 }
892 };
893 (item, result)
894 });
895 true
896 };
897
898 for _ in 0..settings.concurrency() {
899 if !spawn_next(&mut tasks, &mut work) {
900 break;
901 }
902 }
903
904 while let Some((item, result)) = tasks.next().await {
905 let command = match result {
906 Ok(command) => match command.validate_for(&item) {
907 Ok(()) => command,
908 Err(error) => {
909 failed += 1;
910 WorkflowTransitionCommand::failure(
911 &item,
912 error,
913 Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
914 )
915 }
916 },
917 Err(error) => {
918 failed += 1;
919 WorkflowTransitionCommand::failure(
920 &item,
921 error,
922 Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
923 )
924 }
925 };
926 if let Err(error) = queue.commit_command(&item, &command).await {
927 failed += usize::from(!matches!(
928 command.disposition,
929 WorkDisposition::Failed { .. }
930 ));
931 persistence_errors.push(format!("{} command commit: {error}", item.id));
932 }
933 spawn_next(&mut tasks, &mut work);
934 }
935
936 if persistence_errors.is_empty() {
937 Ok(SupervisorStats { claimed, failed })
938 } else {
939 Err(SupervisorError::Commit(persistence_errors.join(", ")))
940 }
941}
942
943#[cfg(test)]
944mod tests {
945 use super::*;
946 use std::sync::atomic::{AtomicBool, Ordering};
947 use std::sync::Mutex;
948
949 struct Context;
950 struct Driver {
951 valid: bool,
952 }
953
954 #[async_trait]
955 impl WorkflowDriver<Context> for Driver {
956 fn name(&self) -> &'static str {
957 "driver"
958 }
959 fn spec_ids(&self) -> Vec<&str> {
960 vec!["spec"]
961 }
962 fn validate_specs(&self) -> Result<(), String> {
963 self.valid.then_some(()).ok_or_else(|| "invalid".into())
964 }
965 fn supports_revision(&self, item: &WorkItem) -> bool {
966 item.workflow_revision_digest == "workflow-digest" && item.kernel_abi_version == "1"
967 }
968 async fn evaluate(
969 &self,
970 _context: &Context,
971 item: &WorkItem,
972 ) -> Result<WorkflowTransitionCommand, String> {
973 if let Some(milliseconds) = item.config["sleep_ms"].as_u64() {
974 tokio::time::sleep(std::time::Duration::from_millis(milliseconds)).await;
975 }
976 if item.config["fail"] == true {
977 Err("planned".into())
978 } else if item.config["reschedule"] == true {
979 Ok(command(
980 item,
981 WorkDisposition::Reschedule { at: Utc::now() },
982 serde_json::json!({"next": true}),
983 ))
984 } else if item.config["stop"] == true {
985 Ok(command(
986 item,
987 WorkDisposition::Complete,
988 item.config.clone(),
989 ))
990 } else {
991 Ok(command(
992 item,
993 WorkDisposition::Continue { delay_secs: 5 },
994 item.config.clone(),
995 ))
996 }
997 }
998 }
999
1000 fn command(
1001 item: &WorkItem,
1002 disposition: WorkDisposition,
1003 next_state: Value,
1004 ) -> WorkflowTransitionCommand {
1005 WorkflowTransitionCommand {
1006 consumed_wakeups: item
1007 .wakeups
1008 .iter()
1009 .map(|wakeup| wakeup.id.clone())
1010 .collect(),
1011 delivery_key: format!("test:{}:{}", item.id, item.state_version),
1012 delivery_digest: "delivery-digest".into(),
1013 event_type: "workflow.test".into(),
1014 event_digest: "event-digest".into(),
1015 event_payload: Value::Null,
1016 next_state,
1017 action_intents: Vec::new(),
1018 outcome: EvaluationOutcome::default(),
1019 disposition,
1020 }
1021 }
1022
1023 #[derive(Default)]
1024 struct Queue {
1025 items: Mutex<Vec<WorkItem>>,
1026 committed: Mutex<Vec<(String, WorkDisposition)>>,
1027 renewals: Mutex<Vec<(String, String, i64)>>,
1028 renewal_error: Mutex<Option<String>>,
1029 finalization_error: Mutex<Option<String>>,
1030 claim_limits: Mutex<Vec<i64>>,
1031 }
1032
1033 #[async_trait]
1034 impl WorkQueue for Queue {
1035 async fn admit_evaluation(&self, _: &WorkItem) -> Result<(), SupervisorError> {
1036 Ok(())
1037 }
1038 async fn claim_due(
1039 &self,
1040 _spec_ids: &[String],
1041 _worker_id: &str,
1042 _lease_secs: i64,
1043 batch: i64,
1044 ) -> Result<Vec<WorkItem>, SupervisorError> {
1045 self.claim_limits.lock().unwrap().push(batch);
1046 let mut items = self.items.lock().unwrap();
1047 let take = items.len().min(batch as usize);
1048 Ok(items.drain(..take).collect())
1049 }
1050
1051 async fn renew(
1052 &self,
1053 _tenant_id: &str,
1054 id: &str,
1055 worker_id: &str,
1056 lease_version: i64,
1057 _lease_secs: i64,
1058 ) -> Result<(), SupervisorError> {
1059 self.renewals.lock().unwrap().push((
1060 id.to_string(),
1061 worker_id.to_string(),
1062 lease_version,
1063 ));
1064 match self.renewal_error.lock().unwrap().clone() {
1065 Some(error) => Err(SupervisorError::Queue(error)),
1066 None => Ok(()),
1067 }
1068 }
1069
1070 async fn commit_command(
1071 &self,
1072 item: &WorkItem,
1073 command: &WorkflowTransitionCommand,
1074 ) -> Result<(), SupervisorError> {
1075 self.committed
1076 .lock()
1077 .unwrap()
1078 .push((item.id.clone(), command.disposition.clone()));
1079 match self.finalization_error.lock().unwrap().clone() {
1080 Some(error) => Err(SupervisorError::Queue(error)),
1081 None => Ok(()),
1082 }
1083 }
1084 }
1085
1086 fn item(id: &str, fail: bool) -> WorkItem {
1087 WorkItem {
1088 id: id.into(),
1089 run_id: uuid::Uuid::new_v4().to_string().parse().unwrap(),
1090 tenant_id: "tenant".parse().unwrap(),
1091 subject_id: "subject".parse().unwrap(),
1092 spec_id: "spec".into(),
1093 definition_id: "definition".into(),
1094 workflow_revision: 1,
1095 workflow_revision_digest: "workflow-digest".into(),
1096 execution_profile_id: "profile".into(),
1097 execution_profile_revision: 1,
1098 execution_profile_digest: "profile-digest".into(),
1099 kernel_abi_version: "1".into(),
1100 capability_pins: Vec::new(),
1101 lifecycle: LifecyclePolicy::run_once(),
1102 scheduled_at: Utc::now(),
1103 created_at: Utc::now(),
1104 claimed_at: Utc::now(),
1105 config: serde_json::json!({"fail": fail}),
1106 state_version: 0,
1107 control_epochs: ControlEpochs::default(),
1108 cancel_requested: false,
1109 lease_version: 1,
1110 wakeups: Vec::new(),
1111 }
1112 }
1113
1114 fn stopped_item(id: &str) -> WorkItem {
1115 WorkItem {
1116 config: serde_json::json!({"stop": true}),
1117 ..item(id, false)
1118 }
1119 }
1120
1121 fn settings() -> SupervisorSettings {
1122 SupervisorSettings {
1123 worker_id: "worker".into(),
1124 lease_secs: 60,
1125 requeue_delay_secs: 5,
1126 claim_batch: 10,
1127 concurrency: 3,
1128 }
1129 }
1130
1131 struct MustNotEvaluate(Arc<AtomicBool>);
1132
1133 #[async_trait]
1134 impl WorkflowDriver<Context> for MustNotEvaluate {
1135 fn name(&self) -> &'static str {
1136 "must-not-evaluate"
1137 }
1138
1139 fn spec_ids(&self) -> Vec<&str> {
1140 vec!["spec"]
1141 }
1142
1143 fn validate_specs(&self) -> Result<(), String> {
1144 Ok(())
1145 }
1146
1147 async fn evaluate(
1148 &self,
1149 _context: &Context,
1150 item: &WorkItem,
1151 ) -> Result<WorkflowTransitionCommand, String> {
1152 self.0.store(true, Ordering::SeqCst);
1153 Ok(command(
1154 item,
1155 WorkDisposition::Complete,
1156 item.config.clone(),
1157 ))
1158 }
1159 }
1160
1161 #[tokio::test]
1162 async fn missing_revision_pin_fails_before_driver_evaluation() {
1163 let mut stale = item("stale", false);
1164 stale.workflow_revision_digest.clear();
1165 let queue = Queue {
1166 items: Mutex::new(vec![stale]),
1167 ..Default::default()
1168 };
1169 let called = Arc::new(AtomicBool::new(false));
1170 let mut registry = DriverRegistry::new();
1171 registry
1172 .register(Arc::new(MustNotEvaluate(called.clone())))
1173 .unwrap();
1174
1175 let stats = run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
1176 .await
1177 .unwrap();
1178
1179 assert_eq!(stats.failed, 1);
1180 assert!(!called.load(Ordering::SeqCst));
1181 assert!(matches!(
1182 queue.committed.lock().unwrap().as_slice(),
1183 [(id, WorkDisposition::Failed { error, .. })]
1184 if id == "stale" && error.contains("workflow_revision_digest")
1185 ));
1186 }
1187
1188 #[tokio::test]
1189 async fn due_pass_releases_success_and_marks_failures() {
1190 let queue = Queue {
1191 items: Mutex::new(vec![
1192 item("ok", false),
1193 stopped_item("done"),
1194 item("bad", true),
1195 ]),
1196 ..Default::default()
1197 };
1198 let mut registry = DriverRegistry::new();
1199 registry.register(Arc::new(Driver { valid: true })).unwrap();
1200 let stats = run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
1201 .await
1202 .unwrap();
1203 assert_eq!(
1204 stats,
1205 SupervisorStats {
1206 claimed: 3,
1207 failed: 1
1208 }
1209 );
1210 let committed = queue.committed.lock().unwrap();
1211 assert_eq!(committed.len(), 3);
1212 assert!(committed
1213 .iter()
1214 .any(|(id, disposition)| id == "done" && disposition == &WorkDisposition::Complete));
1215 assert!(committed.iter().any(|(id, disposition)| id == "bad"
1216 && matches!(disposition, WorkDisposition::Failed { .. })));
1217 }
1218
1219 #[tokio::test]
1220 async fn context_failure_releases_every_claim() {
1221 let queue = Queue {
1222 items: Mutex::new(vec![item("one", false), item("two", false)]),
1223 ..Default::default()
1224 };
1225 let mut registry = DriverRegistry::new();
1226 registry.register(Arc::new(Driver { valid: true })).unwrap();
1227 let error = run_due_pass(&queue, ®istry, &settings(), || async {
1228 Err::<Context, _>("context failed".into())
1229 })
1230 .await
1231 .unwrap_err();
1232 assert_eq!(error, SupervisorError::Context("context failed".into()));
1233 assert_eq!(queue.committed.lock().unwrap().len(), 2);
1234 assert!(queue
1235 .committed
1236 .lock()
1237 .unwrap()
1238 .iter()
1239 .all(|(_, disposition)| matches!(disposition, WorkDisposition::Failed { .. })));
1240 }
1241
1242 #[tokio::test]
1243 async fn due_pass_atomically_reschedules_driver_state() {
1244 let queue = Queue {
1245 items: Mutex::new(vec![WorkItem {
1246 config: serde_json::json!({"reschedule": true}),
1247 ..item("recurring", false)
1248 }]),
1249 ..Default::default()
1250 };
1251 let mut registry = DriverRegistry::new();
1252 registry.register(Arc::new(Driver { valid: true })).unwrap();
1253 run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
1254 .await
1255 .unwrap();
1256 assert!(matches!(
1257 queue.committed.lock().unwrap().as_slice(),
1258 [(id, WorkDisposition::Reschedule { .. })] if id == "recurring"
1259 ));
1260 }
1261
1262 #[tokio::test]
1263 async fn finalization_failure_is_not_reported_as_success() {
1264 let queue = Queue {
1265 items: Mutex::new(vec![item("stale", false)]),
1266 finalization_error: Mutex::new(Some("stale lease".into())),
1267 ..Default::default()
1268 };
1269 let mut registry = DriverRegistry::new();
1270 registry.register(Arc::new(Driver { valid: true })).unwrap();
1271
1272 let error = run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
1273 .await
1274 .unwrap_err();
1275
1276 assert!(
1277 matches!(&error, SupervisorError::Commit(reason) if reason.contains("stale lease"))
1278 );
1279 assert!(error.to_string().contains("state commit failed"));
1280 }
1281
1282 #[tokio::test]
1283 async fn due_pass_claims_only_work_that_can_start() {
1284 let queue = Queue {
1285 items: Mutex::new(vec![
1286 item("one", false),
1287 item("two", false),
1288 item("queued", false),
1289 ]),
1290 ..Default::default()
1291 };
1292 let mut registry = DriverRegistry::new();
1293 registry.register(Arc::new(Driver { valid: true })).unwrap();
1294 let mut settings = settings();
1295 settings.concurrency = 2;
1296
1297 let stats = run_due_pass(&queue, ®istry, &settings, || async { Ok(Context) })
1298 .await
1299 .unwrap();
1300
1301 assert_eq!(stats.claimed, 2);
1302 assert_eq!(queue.claim_limits.lock().unwrap().as_slice(), [2]);
1303 assert_eq!(queue.items.lock().unwrap().len(), 1);
1304 }
1305
1306 #[tokio::test(start_paused = true)]
1307 async fn long_evaluate_renews_its_lease() {
1308 let queue = Queue {
1309 items: Mutex::new(vec![WorkItem {
1310 config: serde_json::json!({"sleep_ms": 3500}),
1311 ..item("slow", false)
1312 }]),
1313 ..Default::default()
1314 };
1315 let mut registry = DriverRegistry::new();
1316 registry.register(Arc::new(Driver { valid: true })).unwrap();
1317 let mut settings = settings();
1318 settings.lease_secs = 3;
1319 settings.concurrency = 1;
1320
1321 let stats = run_due_pass(&queue, ®istry, &settings, || async { Ok(Context) })
1322 .await
1323 .unwrap();
1324
1325 assert_eq!(stats.failed, 0);
1326 let renewals = queue.renewals.lock().unwrap();
1327 assert_eq!(renewals.len(), 3);
1328 assert!(renewals
1329 .iter()
1330 .all(|renewal| renewal == &("slow".into(), "worker".into(), 1)));
1331 assert!(matches!(
1332 queue.committed.lock().unwrap().as_slice(),
1333 [(id, WorkDisposition::Continue { .. })] if id == "slow"
1334 ));
1335 }
1336
1337 #[tokio::test(start_paused = true)]
1338 async fn expired_lease_stops_evaluation() {
1339 let queue = Queue {
1340 items: Mutex::new(vec![WorkItem {
1341 config: serde_json::json!({"sleep_ms": 5000}),
1342 ..item("expired", false)
1343 }]),
1344 renewal_error: Mutex::new(Some("lease expired".into())),
1345 ..Default::default()
1346 };
1347 let mut registry = DriverRegistry::new();
1348 registry.register(Arc::new(Driver { valid: true })).unwrap();
1349 let mut settings = settings();
1350 settings.lease_secs = 3;
1351 settings.concurrency = 1;
1352
1353 let stats = run_due_pass(&queue, ®istry, &settings, || async { Ok(Context) })
1354 .await
1355 .unwrap();
1356
1357 assert_eq!(stats.failed, 1);
1358 assert_eq!(queue.renewals.lock().unwrap().len(), 1);
1359 assert!(matches!(
1360 queue.committed.lock().unwrap().as_slice(),
1361 [(id, WorkDisposition::Failed { .. })] if id == "expired"
1362 ));
1363 }
1364
1365 #[test]
1366 fn registry_fails_duplicate_ownership_and_invalid_specs() {
1367 let mut registry = DriverRegistry::new();
1368 assert!(registry.is_empty());
1369 registry
1370 .register(Arc::new(Driver { valid: false }))
1371 .unwrap();
1372 assert_eq!(registry.spec_ids(), ["spec"]);
1373 assert_eq!(registry.names(), ["driver"]);
1374 assert!(registry.for_spec("spec").is_some());
1375 assert_eq!(
1376 registry.validate_all().unwrap_err(),
1377 SupervisorError::Driver {
1378 driver: "driver".into(),
1379 reason: "invalid".into()
1380 }
1381 );
1382 assert!(matches!(
1383 registry.register(Arc::new(Driver { valid: true })),
1384 Err(SupervisorError::Driver { reason, .. }) if reason.contains("already claimed")
1385 ));
1386 }
1387
1388 #[tokio::test]
1389 async fn memory_work_queue_serves_consumers_without_a_database() {
1390 let queue = MemoryWorkQueue::new(vec![item("first", false)]);
1391 queue.push(item("other-spec", false));
1392 {
1393 let mut items = queue.items.lock().unwrap();
1394 items[1].spec_id = "unknown".into();
1395 }
1396 let mut registry = DriverRegistry::new();
1397 registry.register(Arc::new(Driver { valid: true })).unwrap();
1398 let stats = run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
1399 .await
1400 .unwrap();
1401 assert_eq!(
1402 stats,
1403 SupervisorStats {
1404 claimed: 1,
1405 failed: 0
1406 },
1407 "only items for registered specs are claimed"
1408 );
1409 let committed = queue.committed();
1410 assert_eq!(committed.len(), 1);
1411 assert_eq!(committed[0].0.id, "first");
1412 assert_eq!(
1413 committed[0].1.disposition,
1414 WorkDisposition::Continue { delay_secs: 5 }
1415 );
1416 assert!(queue.renewals().is_empty());
1417 assert_eq!(
1418 run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
1419 .await
1420 .unwrap()
1421 .claimed,
1422 0,
1423 "claimed items leave the queue"
1424 );
1425 }
1426
1427 #[test]
1428 fn commands_are_validated_against_the_claimed_work() {
1429 let work = item("scoped", false);
1430 let mut blank = command(&work, WorkDisposition::Complete, Value::Null);
1431 blank.event_type.clear();
1432 assert!(blank
1433 .validate_for(&work)
1434 .unwrap_err()
1435 .contains("missing event_type"));
1436
1437 let mut cancelled = item("cancelled", false);
1438 cancelled.cancel_requested = true;
1439 let mut with_intent = command(&cancelled, WorkDisposition::Complete, Value::Null);
1440 with_intent.action_intents.push(ActionIntent {
1441 id: "intent".into(),
1442 tenant_id: cancelled.tenant_id.clone(),
1443 instance_id: cancelled.id.clone().parse().unwrap(),
1444 run_id: cancelled.run_id.clone(),
1445 capability: CapabilityPin {
1446 id: "cap".into(),
1447 contract_version: "1".into(),
1448 content_digest: "digest".into(),
1449 },
1450 idempotency_key: "key".into(),
1451 state: crate::ActionState::Prepared,
1452 input: Value::Null,
1453 effect: crate::Effect::ExternalWrite,
1454 retry_class: crate::IdempotencyMode::Native,
1455 control_epochs: ControlEpochs::default(),
1456 resource_scope_id: String::new(),
1457 lease_epoch: cancelled.lease_version,
1458 action_epoch: cancelled.state_version,
1459 deadline: None,
1460 reservation: None,
1461 created_at: Utc::now(),
1462 });
1463 assert!(with_intent
1464 .validate_for(&cancelled)
1465 .unwrap_err()
1466 .contains("cancelled work"));
1467
1468 let mut escaped = with_intent.clone();
1469 escaped.action_intents[0].tenant_id = "someone-else".parse().unwrap();
1470 let scoped = item("scoped", false);
1471 let mut escaped_command = command(&scoped, WorkDisposition::Complete, Value::Null);
1472 escaped_command.action_intents = escaped.action_intents;
1473 assert!(escaped_command
1474 .validate_for(&scoped)
1475 .unwrap_err()
1476 .contains("escapes claimed work scope"));
1477 }
1478}