Skip to main content

af_workflow/
supervisor.rs

1//! Database-agnostic scheduler for claimed long-running workflow instances.
2
3use af_context::{RunId, SubjectId, TenantId};
4use std::collections::HashMap;
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::{ActionIntent, CapabilityPin, ControlEpochs, LifecyclePolicy};
15
16/// A durable fact that became due for a claimed instance: an expired timer or
17/// a pending trigger delivery. The driver reads it; the queue marks it consumed
18/// in the same transaction that commits the driver's command.
19#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
20pub struct Wakeup {
21    /// Stable identifier of this record.
22    pub id: String,
23    /// Discriminator naming the variant of this record.
24    pub kind: String,
25    /// Structured payload.
26    pub payload: Value,
27}
28
29/// One claimed instance with everything a driver needs to evaluate it.
30#[derive(Debug, Clone, PartialEq)]
31pub struct WorkItem {
32    /// Stable identifier of this record.
33    pub id: String,
34    /// Run this record belongs to.
35    pub run_id: RunId,
36    /// Tenant that owns this record.
37    pub tenant_id: TenantId,
38    /// Subject (user or service principal) acting on or owning this record.
39    pub subject_id: SubjectId,
40    /// Workflow spec identifier.
41    pub spec_id: String,
42    /// Workflow definition this record belongs to.
43    pub definition_id: String,
44    /// Pinned workflow revision.
45    pub workflow_revision: u64,
46    /// Digest of the pinned revision.
47    pub workflow_revision_digest: String,
48    /// Pinned execution profile.
49    pub execution_profile_id: String,
50    /// Pinned execution profile revision.
51    pub execution_profile_revision: u64,
52    /// Digest of the pinned profile.
53    pub execution_profile_digest: String,
54    /// Kernel ABI the revision pinned.
55    pub kernel_abi_version: String,
56    /// Capabilities the revision may dispatch.
57    pub capability_pins: Vec<CapabilityPin>,
58    /// Completion, timeout and catch-up policy pinned by the revision.
59    pub lifecycle: LifecyclePolicy,
60    /// Database timestamps captured by the claim transaction. Scheduling logic
61    /// never consults an application-host clock.
62    pub scheduled_at: DateTime<Utc>,
63    /// Database time of the claim.
64    pub claimed_at: DateTime<Utc>,
65    /// Configuration object validated against the declared schema.
66    pub config: Value,
67    /// CAS version the command must be committed against.
68    pub state_version: i64,
69    /// Control epochs at claim time.
70    pub control_epochs: ControlEpochs,
71    /// Whether cancellation was requested; the terminal commit honours it.
72    pub cancel_requested: bool,
73    /// Fencing token bumped on every lease acquisition; stale holders cannot write.
74    pub lease_version: i64,
75    #[doc = "Due timers and trigger deliveries, oldest first."]
76    pub wakeups: Vec<Wakeup>,
77}
78
79impl WorkItem {
80    fn validate_pins(&self) -> Result<(), String> {
81        for (name, value) in [
82            ("definition_id", self.definition_id.as_str()),
83            (
84                "workflow_revision_digest",
85                self.workflow_revision_digest.as_str(),
86            ),
87            ("execution_profile_id", self.execution_profile_id.as_str()),
88            (
89                "execution_profile_digest",
90                self.execution_profile_digest.as_str(),
91            ),
92            ("kernel_abi_version", self.kernel_abi_version.as_str()),
93        ] {
94            if value.trim().is_empty() {
95                return Err(format!("work item is missing pinned {name}"));
96            }
97        }
98        if self.workflow_revision == 0 || self.execution_profile_revision == 0 {
99            return Err("work item revision pins must be positive".into());
100        }
101        uuid::Uuid::parse_str(&self.run_id)
102            .map_err(|error| format!("work item has invalid run_id: {error}"))?;
103        if self.state_version < 0
104            || self.lease_version <= 0
105            || self.control_epochs.tenant < 0
106            || self.control_epochs.instance < 0
107        {
108            return Err("work item state, lease and control pins must be current".into());
109        }
110        Ok(())
111    }
112}
113
114/// What the queue does with the instance after committing a command.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum WorkDisposition {
117    /// Evaluate again after a delay.
118    Continue {
119        /// Seconds until the next evaluation.
120        delay_secs: i64,
121    },
122    /// Evaluate again at an exact time.
123    Reschedule {
124        /// Next evaluation time.
125        at: DateTime<Utc>,
126    },
127    /// The instance is finished.
128    Complete,
129    /// The evaluation failed.
130    Failed {
131        /// Failure reason.
132        error: String,
133        /// When to retry; `None` fails the instance permanently.
134        retry_at: Option<DateTime<Utc>>,
135    },
136}
137
138/// Completion-policy accounting for one evaluation.
139#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
140pub struct EvaluationOutcome {
141    /// The evaluation consumed a trigger or schedule occurrence.
142    pub triggered: bool,
143    /// At least one event passed a material step.
144    pub matched: bool,
145    /// The graph reached a sink or retained an event through its final step.
146    pub succeeded: bool,
147    /// The evaluation observed a terminal action outcome.
148    pub action_terminal: bool,
149}
150
151/// The only result a workflow driver may return. The queue commits the state,
152/// event, intents and scheduling disposition atomically before any provider is
153/// allowed to dispatch an external effect.
154#[derive(Debug, Clone, PartialEq)]
155pub struct WorkflowTransitionCommand {
156    /// Idempotency key of this transition.
157    pub delivery_key: String,
158    /// Content digest; a reused key with a different digest is a conflict.
159    pub delivery_digest: String,
160    /// Stable machine-readable event type.
161    pub event_type: String,
162    /// Digest of the authoritative event.
163    pub event_digest: String,
164    /// Authoritative event payload.
165    pub event_payload: Value,
166    /// New authoritative state.
167    pub next_state: Value,
168    /// Intents to persist in the same transaction.
169    pub action_intents: Vec<ActionIntent>,
170    /// Completion-policy accounting for this transition.
171    pub outcome: EvaluationOutcome,
172    /// Scheduling disposition.
173    pub disposition: WorkDisposition,
174}
175
176impl WorkflowTransitionCommand {
177    /// Command recording an evaluation failure and retrying at `retry_at`.
178    pub fn failure(item: &WorkItem, error: impl Into<String>, retry_at: DateTime<Utc>) -> Self {
179        let error = error.into();
180        Self {
181            delivery_key: format!("supervisor:{}:{}", item.id, item.state_version),
182            delivery_digest: format!("failure:{}:{}", item.lease_version, error),
183            event_type: "workflow.evaluation_failed".into(),
184            event_digest: format!("failure:{}:{}", item.state_version, error),
185            event_payload: serde_json::json!({"error": error.clone()}),
186            next_state: item.config.clone(),
187            action_intents: Vec::new(),
188            outcome: EvaluationOutcome::default(),
189            disposition: WorkDisposition::Failed {
190                error,
191                retry_at: Some(retry_at),
192            },
193        }
194    }
195
196    fn validate_for(&self, item: &WorkItem) -> Result<(), String> {
197        item.validate_pins()?;
198        for (name, value) in [
199            ("delivery_key", self.delivery_key.as_str()),
200            ("delivery_digest", self.delivery_digest.as_str()),
201            ("event_type", self.event_type.as_str()),
202            ("event_digest", self.event_digest.as_str()),
203        ] {
204            if value.trim().is_empty() {
205                return Err(format!("workflow command is missing {name}"));
206            }
207        }
208        if item.cancel_requested && !self.action_intents.is_empty() {
209            return Err("cancelled work cannot prepare external actions".into());
210        }
211        for intent in &self.action_intents {
212            intent
213                .validate_prepared()
214                .map_err(|error| error.to_string())?;
215            if intent.tenant_id != item.tenant_id || intent.instance_id != item.id {
216                return Err("action intent escapes claimed work scope".into());
217            }
218            if intent.run_id != item.run_id {
219                return Err("action intent escapes claimed workflow run".into());
220            }
221            // Resource-scope epochs are owned by the store: the queue does not
222            // know which resource an intent targets, so it only pins what it
223            // claimed (tenant + instance) and the commit transaction proves the
224            // resource epoch against `workflow_control_epochs`.
225            if intent.control_epochs.tenant != item.control_epochs.tenant
226                || intent.control_epochs.instance != item.control_epochs.instance
227                || intent.lease_epoch != item.lease_version
228            {
229                return Err("action intent uses stale control or lease pins".into());
230            }
231            if !item
232                .capability_pins
233                .iter()
234                .any(|pin| pin == &intent.capability)
235            {
236                return Err(format!(
237                    "action capability '{}' is not pinned by the workflow revision",
238                    intent.capability.id
239                ));
240            }
241        }
242        Ok(())
243    }
244}
245
246/// Result of one pass.
247#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
248pub struct SupervisorStats {
249    /// Items claimed.
250    pub claimed: usize,
251    /// Items whose evaluation failed.
252    pub failed: usize,
253}
254
255/// Pass configuration.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct SupervisorSettings {
258    /// Identity of the worker holding the lease.
259    pub worker_id: String,
260    /// Lease duration per claim.
261    pub lease_secs: i64,
262    /// Retry delay after a failed evaluation.
263    pub requeue_delay_secs: i64,
264    /// Maximum items per pass.
265    pub claim_batch: i64,
266    /// Concurrent evaluations.
267    pub concurrency: usize,
268}
269
270impl SupervisorSettings {
271    fn concurrency(&self) -> usize {
272        self.concurrency.max(1)
273    }
274}
275
276/// Failure at the supervisor boundary. Driver evaluation reasons stay `String`
277/// because they are product-authored text recorded verbatim in the failure
278/// event; everything the kernel itself decides is typed here.
279#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
280pub enum SupervisorError {
281    /// The durable work queue could not claim, renew or commit.
282    #[error("work queue: {0}")]
283    Queue(String),
284    /// A driver's spec set failed validation or two drivers claim one spec.
285    #[error("driver '{driver}': {reason}")]
286    Driver {
287        /// Driver name.
288        driver: String,
289        /// Why.
290        reason: String,
291    },
292    /// The per-pass context factory failed; claimed work was requeued.
293    #[error("context: {0}")]
294    Context(String),
295    /// One or more transition commits failed after evaluation.
296    #[error("workflow state commit failed: {0}")]
297    Commit(String),
298}
299
300/// Durable queue the supervisor claims from and commits to.
301#[async_trait]
302pub trait WorkQueue: Send + Sync {
303    /// Claim due instances for `spec_ids`.
304    async fn claim_due(
305        &self,
306        spec_ids: &[String],
307        worker_id: &str,
308        lease_secs: i64,
309        batch: i64,
310    ) -> Result<Vec<WorkItem>, SupervisorError>;
311
312    /// Renew a claim's lease.
313    async fn renew(
314        &self,
315        tenant_id: &str,
316        id: &str,
317        worker_id: &str,
318        lease_version: i64,
319        lease_secs: i64,
320    ) -> Result<(), SupervisorError>;
321    /// Commit a driver command atomically.
322    async fn commit_command(
323        &self,
324        item: &WorkItem,
325        command: &WorkflowTransitionCommand,
326    ) -> Result<(), SupervisorError>;
327}
328
329/// In-memory [`WorkQueue`] for consumer unit tests: hands out queued items in
330/// order and records every committed command. No leases, no durability.
331#[derive(Default)]
332pub struct MemoryWorkQueue {
333    items: std::sync::Mutex<Vec<WorkItem>>,
334    committed: std::sync::Mutex<Vec<(WorkItem, WorkflowTransitionCommand)>>,
335    renewals: std::sync::Mutex<Vec<(String, i64)>>,
336}
337
338impl MemoryWorkQueue {
339    /// Queue pre-loaded with `items`.
340    pub fn new(items: Vec<WorkItem>) -> Self {
341        Self {
342            items: std::sync::Mutex::new(items),
343            ..Self::default()
344        }
345    }
346
347    /// Enqueue an item.
348    pub fn push(&self, item: WorkItem) {
349        self.items
350            .lock()
351            .unwrap_or_else(std::sync::PoisonError::into_inner)
352            .push(item);
353    }
354
355    /// Every `(item, command)` pair committed so far, in commit order.
356    pub fn committed(&self) -> Vec<(WorkItem, WorkflowTransitionCommand)> {
357        self.committed
358            .lock()
359            .unwrap_or_else(std::sync::PoisonError::into_inner)
360            .clone()
361    }
362
363    /// `(instance id, lease version)` for every renewal.
364    pub fn renewals(&self) -> Vec<(String, i64)> {
365        self.renewals
366            .lock()
367            .unwrap_or_else(std::sync::PoisonError::into_inner)
368            .clone()
369    }
370}
371
372#[async_trait]
373impl WorkQueue for MemoryWorkQueue {
374    async fn claim_due(
375        &self,
376        spec_ids: &[String],
377        _worker_id: &str,
378        _lease_secs: i64,
379        batch: i64,
380    ) -> Result<Vec<WorkItem>, SupervisorError> {
381        let mut items = self
382            .items
383            .lock()
384            .unwrap_or_else(std::sync::PoisonError::into_inner);
385        let mut claimed = Vec::new();
386        let mut index = 0;
387        while index < items.len() && claimed.len() < batch.max(0) as usize {
388            if spec_ids.contains(&items[index].spec_id) {
389                claimed.push(items.remove(index));
390            } else {
391                index += 1;
392            }
393        }
394        Ok(claimed)
395    }
396
397    async fn renew(
398        &self,
399        _tenant_id: &str,
400        id: &str,
401        _worker_id: &str,
402        lease_version: i64,
403        _lease_secs: i64,
404    ) -> Result<(), SupervisorError> {
405        self.renewals
406            .lock()
407            .unwrap_or_else(std::sync::PoisonError::into_inner)
408            .push((id.to_owned(), lease_version));
409        Ok(())
410    }
411
412    async fn commit_command(
413        &self,
414        item: &WorkItem,
415        command: &WorkflowTransitionCommand,
416    ) -> Result<(), SupervisorError> {
417        self.committed
418            .lock()
419            .unwrap_or_else(std::sync::PoisonError::into_inner)
420            .push((item.clone(), command.clone()));
421        Ok(())
422    }
423}
424
425/// Deterministic evaluator for the specs it owns.
426#[async_trait]
427pub trait WorkflowDriver<Context>: Send + Sync
428where
429    Context: Send + Sync,
430{
431    /// Stable driver name.
432    fn name(&self) -> &'static str;
433    /// Specs this driver owns.
434    fn spec_ids(&self) -> Vec<&str>;
435    /// Reject a spec set the driver cannot serve; the reason is reported as
436    /// [`SupervisorError::Driver`].
437    fn validate_specs(&self) -> Result<(), String>;
438    /// Evaluate deterministic workflow state and return one durable command.
439    /// External effects must be represented as prepared `ActionIntent`s and
440    /// dispatched later by a registered provider.
441    async fn evaluate(
442        &self,
443        context: &Context,
444        item: &WorkItem,
445    ) -> Result<WorkflowTransitionCommand, String>;
446}
447
448/// Drivers keyed by the specs they own.
449pub struct DriverRegistry<Context: Send + Sync> {
450    drivers: Vec<Arc<dyn WorkflowDriver<Context>>>,
451    by_spec: HashMap<String, Arc<dyn WorkflowDriver<Context>>>,
452}
453
454impl<Context: Send + Sync> Default for DriverRegistry<Context> {
455    fn default() -> Self {
456        Self {
457            drivers: Vec::new(),
458            by_spec: HashMap::new(),
459        }
460    }
461}
462
463impl<Context: Send + Sync> DriverRegistry<Context> {
464    /// Empty registry.
465    pub fn new() -> Self {
466        Self::default()
467    }
468
469    /// Register a driver. Duplicate spec ownership is a startup error because
470    /// dispatch must never depend on registration order.
471    pub fn register(
472        &mut self,
473        driver: Arc<dyn WorkflowDriver<Context>>,
474    ) -> Result<&mut Self, SupervisorError> {
475        for spec_id in driver.spec_ids() {
476            if let Some(existing) = self.by_spec.get(spec_id) {
477                return Err(SupervisorError::Driver {
478                    driver: driver.name().to_owned(),
479                    reason: format!(
480                        "spec '{spec_id}' is already claimed by '{}'",
481                        existing.name()
482                    ),
483                });
484            }
485            self.by_spec.insert(spec_id.to_owned(), driver.clone());
486        }
487        self.drivers.push(driver);
488        Ok(self)
489    }
490
491    /// Every owned spec id, sorted.
492    pub fn spec_ids(&self) -> Vec<String> {
493        let mut ids = self.by_spec.keys().cloned().collect::<Vec<_>>();
494        ids.sort();
495        ids
496    }
497
498    /// Driver owning `spec_id`.
499    pub fn for_spec(&self, spec_id: &str) -> Option<&Arc<dyn WorkflowDriver<Context>>> {
500        self.by_spec.get(spec_id)
501    }
502
503    /// Registered driver names.
504    pub fn names(&self) -> Vec<&'static str> {
505        self.drivers.iter().map(|driver| driver.name()).collect()
506    }
507
508    /// Whether no driver is registered.
509    pub fn is_empty(&self) -> bool {
510        self.drivers.is_empty()
511    }
512
513    /// Validate every driver's spec set.
514    pub fn validate_all(&self) -> Result<(), SupervisorError> {
515        for driver in &self.drivers {
516            driver
517                .validate_specs()
518                .map_err(|reason| SupervisorError::Driver {
519                    driver: driver.name().to_owned(),
520                    reason,
521                })?;
522        }
523        Ok(())
524    }
525}
526
527/// One supervisor pass: claim due work, evaluate concurrently under renewed leases, commit every command.
528/// A failing context factory requeues every claimed item with the failure.
529pub async fn run_due_pass<Context, Queue, BuildContext, BuildFuture>(
530    queue: &Queue,
531    registry: &DriverRegistry<Context>,
532    settings: &SupervisorSettings,
533    build_context: BuildContext,
534) -> Result<SupervisorStats, SupervisorError>
535where
536    Context: Send + Sync + 'static,
537    Queue: WorkQueue,
538    BuildContext: FnOnce() -> BuildFuture,
539    BuildFuture: Future<Output = Result<Context, String>> + Send,
540{
541    let claim_limit = settings
542        .claim_batch
543        .clamp(1, i64::try_from(settings.concurrency()).unwrap_or(i64::MAX));
544    let items = queue
545        .claim_due(
546            &registry.spec_ids(),
547            &settings.worker_id,
548            settings.lease_secs,
549            claim_limit,
550        )
551        .await?;
552    if items.is_empty() {
553        return Ok(SupervisorStats::default());
554    }
555
556    let context = match build_context().await {
557        Ok(context) => Arc::new(context),
558        Err(error) => {
559            let mut cleanup_errors = Vec::new();
560            for item in &items {
561                let command = WorkflowTransitionCommand::failure(
562                    item,
563                    error.clone(),
564                    Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
565                );
566                if let Err(cleanup) = queue.commit_command(item, &command).await {
567                    cleanup_errors.push(format!("{} commit failure: {cleanup}", item.id));
568                }
569            }
570            if !cleanup_errors.is_empty() {
571                return Err(SupervisorError::Context(format!(
572                    "{error}; claimed work cleanup failed: {}",
573                    cleanup_errors.join(", ")
574                )));
575            }
576            return Err(SupervisorError::Context(error));
577        }
578    };
579
580    let claimed = items.len();
581    let mut failed = 0;
582    let mut persistence_errors = Vec::new();
583    let mut work = items.into_iter();
584    let mut tasks = FuturesUnordered::new();
585
586    let spawn_next = |tasks: &mut FuturesUnordered<_>, work: &mut std::vec::IntoIter<WorkItem>| {
587        let Some(item) = work.next() else {
588            return false;
589        };
590        let driver = registry.for_spec(&item.spec_id).cloned();
591        let context = context.clone();
592        let renewal_period = std::time::Duration::from_millis(
593            (settings.lease_secs.clamp(1, 3600) as u64 * 1000 / 3).max(1),
594        );
595        tasks.push(async move {
596            let evaluation_item = item.clone();
597            let evaluation = async {
598                evaluation_item.validate_pins()?;
599                match driver {
600                    Some(driver) => AssertUnwindSafe(driver.evaluate(&context, &evaluation_item))
601                        .catch_unwind()
602                        .await
603                        .map_err(|_| "driver panicked".to_string())
604                        .and_then(|result| result),
605                    None => Err(format!(
606                        "no driver registered for spec '{}'",
607                        evaluation_item.spec_id
608                    )),
609                }
610            };
611            tokio::pin!(evaluation);
612            let mut renewal = tokio::time::interval_at(
613                tokio::time::Instant::now() + renewal_period,
614                renewal_period,
615            );
616            renewal.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
617            let result = loop {
618                tokio::select! {
619                    result = &mut evaluation => break result,
620                    _ = renewal.tick() => {
621                        if let Err(error) = queue
622                            .renew(
623                                &item.tenant_id,
624                                &item.id,
625                                &settings.worker_id,
626                                item.lease_version,
627                                settings.lease_secs,
628                            )
629                            .await
630                        {
631                            break Err(format!("lease renewal failed: {error}"));
632                        }
633                    }
634                }
635            };
636            (item, result)
637        });
638        true
639    };
640
641    for _ in 0..settings.concurrency() {
642        if !spawn_next(&mut tasks, &mut work) {
643            break;
644        }
645    }
646
647    while let Some((item, result)) = tasks.next().await {
648        let command = match result {
649            Ok(command) => match command.validate_for(&item) {
650                Ok(()) => command,
651                Err(error) => {
652                    failed += 1;
653                    WorkflowTransitionCommand::failure(
654                        &item,
655                        error,
656                        Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
657                    )
658                }
659            },
660            Err(error) => {
661                failed += 1;
662                WorkflowTransitionCommand::failure(
663                    &item,
664                    error,
665                    Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
666                )
667            }
668        };
669        if let Err(error) = queue.commit_command(&item, &command).await {
670            failed += usize::from(!matches!(
671                command.disposition,
672                WorkDisposition::Failed { .. }
673            ));
674            persistence_errors.push(format!("{} command commit: {error}", item.id));
675        }
676        spawn_next(&mut tasks, &mut work);
677    }
678
679    if persistence_errors.is_empty() {
680        Ok(SupervisorStats { claimed, failed })
681    } else {
682        Err(SupervisorError::Commit(persistence_errors.join(", ")))
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use std::sync::atomic::{AtomicBool, Ordering};
690    use std::sync::Mutex;
691
692    struct Context;
693    struct Driver {
694        valid: bool,
695    }
696
697    #[async_trait]
698    impl WorkflowDriver<Context> for Driver {
699        fn name(&self) -> &'static str {
700            "driver"
701        }
702        fn spec_ids(&self) -> Vec<&str> {
703            vec!["spec"]
704        }
705        fn validate_specs(&self) -> Result<(), String> {
706            self.valid.then_some(()).ok_or_else(|| "invalid".into())
707        }
708        async fn evaluate(
709            &self,
710            _context: &Context,
711            item: &WorkItem,
712        ) -> Result<WorkflowTransitionCommand, String> {
713            if let Some(milliseconds) = item.config["sleep_ms"].as_u64() {
714                tokio::time::sleep(std::time::Duration::from_millis(milliseconds)).await;
715            }
716            if item.config["fail"] == true {
717                Err("planned".into())
718            } else if item.config["reschedule"] == true {
719                Ok(command(
720                    item,
721                    WorkDisposition::Reschedule { at: Utc::now() },
722                    serde_json::json!({"next": true}),
723                ))
724            } else if item.config["stop"] == true {
725                Ok(command(
726                    item,
727                    WorkDisposition::Complete,
728                    item.config.clone(),
729                ))
730            } else {
731                Ok(command(
732                    item,
733                    WorkDisposition::Continue { delay_secs: 5 },
734                    item.config.clone(),
735                ))
736            }
737        }
738    }
739
740    fn command(
741        item: &WorkItem,
742        disposition: WorkDisposition,
743        next_state: Value,
744    ) -> WorkflowTransitionCommand {
745        WorkflowTransitionCommand {
746            delivery_key: format!("test:{}:{}", item.id, item.state_version),
747            delivery_digest: "delivery-digest".into(),
748            event_type: "workflow.test".into(),
749            event_digest: "event-digest".into(),
750            event_payload: Value::Null,
751            next_state,
752            action_intents: Vec::new(),
753            outcome: EvaluationOutcome::default(),
754            disposition,
755        }
756    }
757
758    #[derive(Default)]
759    struct Queue {
760        items: Mutex<Vec<WorkItem>>,
761        committed: Mutex<Vec<(String, WorkDisposition)>>,
762        renewals: Mutex<Vec<(String, String, i64)>>,
763        renewal_error: Mutex<Option<String>>,
764        finalization_error: Mutex<Option<String>>,
765        claim_limits: Mutex<Vec<i64>>,
766    }
767
768    #[async_trait]
769    impl WorkQueue for Queue {
770        async fn claim_due(
771            &self,
772            _spec_ids: &[String],
773            _worker_id: &str,
774            _lease_secs: i64,
775            batch: i64,
776        ) -> Result<Vec<WorkItem>, SupervisorError> {
777            self.claim_limits.lock().unwrap().push(batch);
778            let mut items = self.items.lock().unwrap();
779            let take = items.len().min(batch as usize);
780            Ok(items.drain(..take).collect())
781        }
782
783        async fn renew(
784            &self,
785            _tenant_id: &str,
786            id: &str,
787            worker_id: &str,
788            lease_version: i64,
789            _lease_secs: i64,
790        ) -> Result<(), SupervisorError> {
791            self.renewals.lock().unwrap().push((
792                id.to_string(),
793                worker_id.to_string(),
794                lease_version,
795            ));
796            match self.renewal_error.lock().unwrap().clone() {
797                Some(error) => Err(SupervisorError::Queue(error)),
798                None => Ok(()),
799            }
800        }
801
802        async fn commit_command(
803            &self,
804            item: &WorkItem,
805            command: &WorkflowTransitionCommand,
806        ) -> Result<(), SupervisorError> {
807            self.committed
808                .lock()
809                .unwrap()
810                .push((item.id.clone(), command.disposition.clone()));
811            match self.finalization_error.lock().unwrap().clone() {
812                Some(error) => Err(SupervisorError::Queue(error)),
813                None => Ok(()),
814            }
815        }
816    }
817
818    fn item(id: &str, fail: bool) -> WorkItem {
819        WorkItem {
820            id: id.into(),
821            run_id: uuid::Uuid::new_v4().to_string().parse().unwrap(),
822            tenant_id: "tenant".parse().unwrap(),
823            subject_id: "subject".parse().unwrap(),
824            spec_id: "spec".into(),
825            definition_id: "definition".into(),
826            workflow_revision: 1,
827            workflow_revision_digest: "workflow-digest".into(),
828            execution_profile_id: "profile".into(),
829            execution_profile_revision: 1,
830            execution_profile_digest: "profile-digest".into(),
831            kernel_abi_version: "1".into(),
832            capability_pins: Vec::new(),
833            lifecycle: LifecyclePolicy::run_once(),
834            scheduled_at: Utc::now(),
835            claimed_at: Utc::now(),
836            config: serde_json::json!({"fail": fail}),
837            state_version: 0,
838            control_epochs: ControlEpochs::default(),
839            cancel_requested: false,
840            lease_version: 1,
841            wakeups: Vec::new(),
842        }
843    }
844
845    fn stopped_item(id: &str) -> WorkItem {
846        WorkItem {
847            config: serde_json::json!({"stop": true}),
848            ..item(id, false)
849        }
850    }
851
852    fn settings() -> SupervisorSettings {
853        SupervisorSettings {
854            worker_id: "worker".into(),
855            lease_secs: 60,
856            requeue_delay_secs: 5,
857            claim_batch: 10,
858            concurrency: 3,
859        }
860    }
861
862    struct MustNotEvaluate(Arc<AtomicBool>);
863
864    #[async_trait]
865    impl WorkflowDriver<Context> for MustNotEvaluate {
866        fn name(&self) -> &'static str {
867            "must-not-evaluate"
868        }
869
870        fn spec_ids(&self) -> Vec<&str> {
871            vec!["spec"]
872        }
873
874        fn validate_specs(&self) -> Result<(), String> {
875            Ok(())
876        }
877
878        async fn evaluate(
879            &self,
880            _context: &Context,
881            item: &WorkItem,
882        ) -> Result<WorkflowTransitionCommand, String> {
883            self.0.store(true, Ordering::SeqCst);
884            Ok(command(
885                item,
886                WorkDisposition::Complete,
887                item.config.clone(),
888            ))
889        }
890    }
891
892    #[tokio::test]
893    async fn missing_revision_pin_fails_before_driver_evaluation() {
894        let mut stale = item("stale", false);
895        stale.workflow_revision_digest.clear();
896        let queue = Queue {
897            items: Mutex::new(vec![stale]),
898            ..Default::default()
899        };
900        let called = Arc::new(AtomicBool::new(false));
901        let mut registry = DriverRegistry::new();
902        registry
903            .register(Arc::new(MustNotEvaluate(called.clone())))
904            .unwrap();
905
906        let stats = run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
907            .await
908            .unwrap();
909
910        assert_eq!(stats.failed, 1);
911        assert!(!called.load(Ordering::SeqCst));
912        assert!(matches!(
913            queue.committed.lock().unwrap().as_slice(),
914            [(id, WorkDisposition::Failed { error, .. })]
915                if id == "stale" && error.contains("workflow_revision_digest")
916        ));
917    }
918
919    #[tokio::test]
920    async fn due_pass_releases_success_and_marks_failures() {
921        let queue = Queue {
922            items: Mutex::new(vec![
923                item("ok", false),
924                stopped_item("done"),
925                item("bad", true),
926            ]),
927            ..Default::default()
928        };
929        let mut registry = DriverRegistry::new();
930        registry.register(Arc::new(Driver { valid: true })).unwrap();
931        let stats = run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
932            .await
933            .unwrap();
934        assert_eq!(
935            stats,
936            SupervisorStats {
937                claimed: 3,
938                failed: 1
939            }
940        );
941        let committed = queue.committed.lock().unwrap();
942        assert_eq!(committed.len(), 3);
943        assert!(committed
944            .iter()
945            .any(|(id, disposition)| id == "done" && disposition == &WorkDisposition::Complete));
946        assert!(committed.iter().any(|(id, disposition)| id == "bad"
947            && matches!(disposition, WorkDisposition::Failed { .. })));
948    }
949
950    #[tokio::test]
951    async fn context_failure_releases_every_claim() {
952        let queue = Queue {
953            items: Mutex::new(vec![item("one", false), item("two", false)]),
954            ..Default::default()
955        };
956        let mut registry = DriverRegistry::new();
957        registry.register(Arc::new(Driver { valid: true })).unwrap();
958        let error = run_due_pass(&queue, &registry, &settings(), || async {
959            Err::<Context, _>("context failed".into())
960        })
961        .await
962        .unwrap_err();
963        assert_eq!(error, SupervisorError::Context("context failed".into()));
964        assert_eq!(queue.committed.lock().unwrap().len(), 2);
965        assert!(queue
966            .committed
967            .lock()
968            .unwrap()
969            .iter()
970            .all(|(_, disposition)| matches!(disposition, WorkDisposition::Failed { .. })));
971    }
972
973    #[tokio::test]
974    async fn due_pass_atomically_reschedules_driver_state() {
975        let queue = Queue {
976            items: Mutex::new(vec![WorkItem {
977                config: serde_json::json!({"reschedule": true}),
978                ..item("recurring", false)
979            }]),
980            ..Default::default()
981        };
982        let mut registry = DriverRegistry::new();
983        registry.register(Arc::new(Driver { valid: true })).unwrap();
984        run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
985            .await
986            .unwrap();
987        assert!(matches!(
988            queue.committed.lock().unwrap().as_slice(),
989            [(id, WorkDisposition::Reschedule { .. })] if id == "recurring"
990        ));
991    }
992
993    #[tokio::test]
994    async fn finalization_failure_is_not_reported_as_success() {
995        let queue = Queue {
996            items: Mutex::new(vec![item("stale", false)]),
997            finalization_error: Mutex::new(Some("stale lease".into())),
998            ..Default::default()
999        };
1000        let mut registry = DriverRegistry::new();
1001        registry.register(Arc::new(Driver { valid: true })).unwrap();
1002
1003        let error = run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
1004            .await
1005            .unwrap_err();
1006
1007        assert!(
1008            matches!(&error, SupervisorError::Commit(reason) if reason.contains("stale lease"))
1009        );
1010        assert!(error.to_string().contains("state commit failed"));
1011    }
1012
1013    #[tokio::test]
1014    async fn due_pass_claims_only_work_that_can_start() {
1015        let queue = Queue {
1016            items: Mutex::new(vec![
1017                item("one", false),
1018                item("two", false),
1019                item("queued", false),
1020            ]),
1021            ..Default::default()
1022        };
1023        let mut registry = DriverRegistry::new();
1024        registry.register(Arc::new(Driver { valid: true })).unwrap();
1025        let mut settings = settings();
1026        settings.concurrency = 2;
1027
1028        let stats = run_due_pass(&queue, &registry, &settings, || async { Ok(Context) })
1029            .await
1030            .unwrap();
1031
1032        assert_eq!(stats.claimed, 2);
1033        assert_eq!(queue.claim_limits.lock().unwrap().as_slice(), [2]);
1034        assert_eq!(queue.items.lock().unwrap().len(), 1);
1035    }
1036
1037    #[tokio::test(start_paused = true)]
1038    async fn long_evaluate_renews_its_lease() {
1039        let queue = Queue {
1040            items: Mutex::new(vec![WorkItem {
1041                config: serde_json::json!({"sleep_ms": 3500}),
1042                ..item("slow", false)
1043            }]),
1044            ..Default::default()
1045        };
1046        let mut registry = DriverRegistry::new();
1047        registry.register(Arc::new(Driver { valid: true })).unwrap();
1048        let mut settings = settings();
1049        settings.lease_secs = 3;
1050        settings.concurrency = 1;
1051
1052        let stats = run_due_pass(&queue, &registry, &settings, || async { Ok(Context) })
1053            .await
1054            .unwrap();
1055
1056        assert_eq!(stats.failed, 0);
1057        let renewals = queue.renewals.lock().unwrap();
1058        assert_eq!(renewals.len(), 3);
1059        assert!(renewals
1060            .iter()
1061            .all(|renewal| renewal == &("slow".into(), "worker".into(), 1)));
1062        assert!(matches!(
1063            queue.committed.lock().unwrap().as_slice(),
1064            [(id, WorkDisposition::Continue { .. })] if id == "slow"
1065        ));
1066    }
1067
1068    #[tokio::test(start_paused = true)]
1069    async fn expired_lease_stops_evaluation() {
1070        let queue = Queue {
1071            items: Mutex::new(vec![WorkItem {
1072                config: serde_json::json!({"sleep_ms": 5000}),
1073                ..item("expired", false)
1074            }]),
1075            renewal_error: Mutex::new(Some("lease expired".into())),
1076            ..Default::default()
1077        };
1078        let mut registry = DriverRegistry::new();
1079        registry.register(Arc::new(Driver { valid: true })).unwrap();
1080        let mut settings = settings();
1081        settings.lease_secs = 3;
1082        settings.concurrency = 1;
1083
1084        let stats = run_due_pass(&queue, &registry, &settings, || async { Ok(Context) })
1085            .await
1086            .unwrap();
1087
1088        assert_eq!(stats.failed, 1);
1089        assert_eq!(queue.renewals.lock().unwrap().len(), 1);
1090        assert!(matches!(
1091            queue.committed.lock().unwrap().as_slice(),
1092            [(id, WorkDisposition::Failed { .. })] if id == "expired"
1093        ));
1094    }
1095
1096    #[test]
1097    fn registry_fails_duplicate_ownership_and_invalid_specs() {
1098        let mut registry = DriverRegistry::new();
1099        assert!(registry.is_empty());
1100        registry
1101            .register(Arc::new(Driver { valid: false }))
1102            .unwrap();
1103        assert_eq!(registry.spec_ids(), ["spec"]);
1104        assert_eq!(registry.names(), ["driver"]);
1105        assert!(registry.for_spec("spec").is_some());
1106        assert_eq!(
1107            registry.validate_all().unwrap_err(),
1108            SupervisorError::Driver {
1109                driver: "driver".into(),
1110                reason: "invalid".into()
1111            }
1112        );
1113        assert!(matches!(
1114            registry.register(Arc::new(Driver { valid: true })),
1115            Err(SupervisorError::Driver { reason, .. }) if reason.contains("already claimed")
1116        ));
1117    }
1118
1119    #[tokio::test]
1120    async fn memory_work_queue_serves_consumers_without_a_database() {
1121        let queue = MemoryWorkQueue::new(vec![item("first", false)]);
1122        queue.push(item("other-spec", false));
1123        {
1124            let mut items = queue.items.lock().unwrap();
1125            items[1].spec_id = "unknown".into();
1126        }
1127        let mut registry = DriverRegistry::new();
1128        registry.register(Arc::new(Driver { valid: true })).unwrap();
1129        let stats = run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
1130            .await
1131            .unwrap();
1132        assert_eq!(
1133            stats,
1134            SupervisorStats {
1135                claimed: 1,
1136                failed: 0
1137            },
1138            "only items for registered specs are claimed"
1139        );
1140        let committed = queue.committed();
1141        assert_eq!(committed.len(), 1);
1142        assert_eq!(committed[0].0.id, "first");
1143        assert_eq!(
1144            committed[0].1.disposition,
1145            WorkDisposition::Continue { delay_secs: 5 }
1146        );
1147        assert!(queue.renewals().is_empty());
1148        assert_eq!(
1149            run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
1150                .await
1151                .unwrap()
1152                .claimed,
1153            0,
1154            "claimed items leave the queue"
1155        );
1156    }
1157
1158    #[test]
1159    fn commands_are_validated_against_the_claimed_work() {
1160        let work = item("scoped", false);
1161        let mut blank = command(&work, WorkDisposition::Complete, Value::Null);
1162        blank.event_type.clear();
1163        assert!(blank
1164            .validate_for(&work)
1165            .unwrap_err()
1166            .contains("missing event_type"));
1167
1168        let mut cancelled = item("cancelled", false);
1169        cancelled.cancel_requested = true;
1170        let mut with_intent = command(&cancelled, WorkDisposition::Complete, Value::Null);
1171        with_intent.action_intents.push(ActionIntent {
1172            id: "intent".into(),
1173            tenant_id: cancelled.tenant_id.clone(),
1174            instance_id: cancelled.id.clone().parse().unwrap(),
1175            run_id: cancelled.run_id.clone(),
1176            capability: CapabilityPin {
1177                id: "cap".into(),
1178                contract_version: "1".into(),
1179                content_digest: "digest".into(),
1180            },
1181            idempotency_key: "key".into(),
1182            state: crate::ActionState::Prepared,
1183            input: Value::Null,
1184            effect: crate::Effect::ExternalWrite,
1185            retry_class: crate::IdempotencyMode::Native,
1186            control_epochs: ControlEpochs::default(),
1187            resource_scope_id: String::new(),
1188            lease_epoch: cancelled.lease_version,
1189            action_epoch: cancelled.state_version,
1190            deadline: None,
1191            reservation: None,
1192            created_at: Utc::now(),
1193        });
1194        assert!(with_intent
1195            .validate_for(&cancelled)
1196            .unwrap_err()
1197            .contains("cancelled work"));
1198
1199        let mut escaped = with_intent.clone();
1200        escaped.action_intents[0].tenant_id = "someone-else".parse().unwrap();
1201        let scoped = item("scoped", false);
1202        let mut escaped_command = command(&scoped, WorkDisposition::Complete, Value::Null);
1203        escaped_command.action_intents = escaped.action_intents;
1204        assert!(escaped_command
1205            .validate_for(&scoped)
1206            .unwrap_err()
1207            .contains("escapes claimed work scope"));
1208    }
1209}