Skip to main content

nexus_sdk/scheduler/
model.rs

1//! Values used to author Tasks and Schedules.
2
3use {
4    crate::{
5        scheduler::ScheduleError,
6        sui,
7        types::{
8            AgentId,
9            NexusData,
10            SkillId,
11            DEFAULT_PRIORITY_FEE_PERCENTAGE,
12            MAX_PRIORITY_FEE_PERCENTAGE,
13            MIN_PRIORITY_FEE_PERCENTAGE,
14        },
15    },
16    serde::{Deserialize, Serialize},
17    std::collections::BTreeMap,
18};
19
20/// Input values keyed first by vertex name and then by input port name.
21pub type TaskInputs = BTreeMap<String, BTreeMap<String, NexusData>>;
22
23/// Work, funding, and failure behavior shared by every Task occurrence.
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct TaskSpec {
26    operation: TaskOperation,
27    entry_group: String,
28    inputs: TaskInputs,
29    funding: TaskFunding,
30    occurrence_budget_mist: u64,
31    failure_policy: FailurePolicy,
32}
33
34impl TaskSpec {
35    /// Creates a Task definition with no inputs and [`FailurePolicy::Continue`].
36    ///
37    /// # Errors
38    ///
39    /// Returns [`ScheduleError::EmptyEntryGroup`] when `entry_group` is blank,
40    /// or [`ScheduleError::ZeroOccurrenceBudget`] when
41    /// `occurrence_budget_mist` is zero.
42    pub fn new(
43        operation: TaskOperation,
44        entry_group: impl Into<String>,
45        funding: TaskFunding,
46        occurrence_budget_mist: u64,
47    ) -> Result<Self, ScheduleError> {
48        let task = Self {
49            operation,
50            entry_group: entry_group.into(),
51            inputs: BTreeMap::new(),
52            funding,
53            occurrence_budget_mist,
54            failure_policy: FailurePolicy::Continue,
55        };
56        task.validate()?;
57        Ok(task)
58    }
59
60    /// Replaces the Task's vertex input values.
61    #[must_use]
62    pub fn with_inputs(mut self, inputs: TaskInputs) -> Self {
63        self.inputs = inputs;
64        self
65    }
66
67    /// Replaces the behavior applied after a failed occurrence.
68    #[must_use]
69    pub const fn with_failure_policy(mut self, policy: FailurePolicy) -> Self {
70        self.failure_policy = policy;
71        self
72    }
73
74    /// Returns the operation performed by every occurrence.
75    pub const fn operation(&self) -> &TaskOperation {
76        &self.operation
77    }
78
79    /// Returns the selected DAG entry group name.
80    pub fn entry_group(&self) -> &str {
81        &self.entry_group
82    }
83
84    /// Returns the input values keyed by vertex and port.
85    pub const fn inputs(&self) -> &TaskInputs {
86        &self.inputs
87    }
88
89    /// Returns the Task's funding and controller choice.
90    pub const fn funding(&self) -> TaskFunding {
91        self.funding
92    }
93
94    /// Returns the maximum amount one occurrence may consume.
95    pub const fn occurrence_budget_mist(&self) -> u64 {
96        self.occurrence_budget_mist
97    }
98
99    /// Returns the behavior applied after a failed occurrence.
100    pub const fn failure_policy(&self) -> FailurePolicy {
101        self.failure_policy
102    }
103
104    pub(crate) fn validate(&self) -> Result<(), ScheduleError> {
105        if self.entry_group.trim().is_empty() {
106            return Err(ScheduleError::EmptyEntryGroup);
107        }
108        if self.occurrence_budget_mist == 0 {
109            return Err(ScheduleError::ZeroOccurrenceBudget);
110        }
111        if matches!(self.operation, TaskOperation::DefaultDag { .. })
112            && matches!(self.funding, TaskFunding::Agent { .. })
113        {
114            return Err(ScheduleError::IncompatibleFunding {
115                message: "a default DAG Task cannot use Agent vault funding",
116            });
117        }
118        Ok(())
119    }
120}
121
122/// Operation performed by every occurrence of a Task.
123#[derive(Clone, Debug, Serialize, Deserialize)]
124#[serde(tag = "kind", rename_all = "snake_case")]
125pub enum TaskOperation {
126    /// Runs a published DAG through the network's default Agent.
127    DefaultDag {
128        /// Published DAG object identifier.
129        dag_id: sui::types::Address,
130    },
131    /// Runs one registered Agent skill.
132    AgentSkill {
133        /// Agent object identifier.
134        agent_id: AgentId,
135        /// Agent local skill identifier.
136        skill_id: SkillId,
137        /// Optional DAG selected for the skill.
138        selected_dag: Option<sui::types::Address>,
139        /// Recipients authorized at grant requiring DAG vertices.
140        authorization_bindings: AuthorizationBindings,
141    },
142}
143
144impl TaskOperation {
145    /// Selects a published DAG for the network's default Agent.
146    pub const fn default_dag(dag_id: sui::types::Address) -> Self {
147        Self::DefaultDag { dag_id }
148    }
149
150    /// Selects one registered Agent skill.
151    pub fn agent_skill(
152        agent_id: AgentId,
153        skill_id: SkillId,
154        selected_dag: Option<sui::types::Address>,
155        authorization_bindings: AuthorizationBindings,
156    ) -> Self {
157        Self::AgentSkill {
158            agent_id,
159            skill_id,
160            selected_dag,
161            authorization_bindings,
162        }
163    }
164
165    /// Returns the selected Agent when this is an Agent skill operation.
166    pub const fn agent_id(&self) -> Option<AgentId> {
167        match self {
168            Self::DefaultDag { .. } => None,
169            Self::AgentSkill { agent_id, .. } => Some(*agent_id),
170        }
171    }
172
173    /// Returns the DAG selected directly by this operation.
174    ///
175    /// An Agent skill with no selection resolves its DAG from the active skill
176    /// registration during dispatch.
177    pub const fn selected_dag_id(&self) -> Option<sui::types::Address> {
178        match self {
179            Self::DefaultDag { dag_id } => Some(*dag_id),
180            Self::AgentSkill { selected_dag, .. } => *selected_dag,
181        }
182    }
183}
184
185/// Authorization recipients indexed by their grant requiring DAG vertex.
186///
187/// The selected [`TaskOperation::AgentSkill`] already identifies the Agent and
188/// skill. Each entry therefore contains only the remaining caller choice: the
189/// object that receives the grant for one vertex. Task admission checks that
190/// this map exactly covers the selected DAG requirements.
191pub type AuthorizationBindings = BTreeMap<String, sui::types::Address>;
192
193/// Funding source and immutable controller for a Task.
194#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(tag = "kind", rename_all = "snake_case")]
196pub enum TaskFunding {
197    /// Reserves sender owned SUI and assigns address control.
198    Address {
199        /// Amount reserved when the Task is created.
200        prepay_amount_mist: u64,
201        /// Optional recipient of unused reserved funds.
202        refund_recipient: Option<sui::types::Address>,
203    },
204    /// Reserves funds from the selected Agent and assigns Agent control.
205    Agent {
206        /// Amount reserved from the Agent's payment vault.
207        prepay_amount_mist: u64,
208    },
209}
210
211impl TaskFunding {
212    /// Uses sender funds and refunds unused funds to the sender.
213    pub const fn address(prepay_amount_mist: u64) -> Self {
214        Self::Address {
215            prepay_amount_mist,
216            refund_recipient: None,
217        }
218    }
219
220    /// Uses sender funds with an explicit refund recipient.
221    pub const fn address_with_refund(
222        prepay_amount_mist: u64,
223        refund_recipient: sui::types::Address,
224    ) -> Self {
225        Self::Address {
226            prepay_amount_mist,
227            refund_recipient: Some(refund_recipient),
228        }
229    }
230
231    /// Uses the selected Agent's payment vault.
232    pub const fn agent(prepay_amount_mist: u64) -> Self {
233        Self::Agent { prepay_amount_mist }
234    }
235
236    /// Returns the amount reserved when the Task is created.
237    pub const fn prepay_amount_mist(self) -> u64 {
238        match self {
239            Self::Address {
240                prepay_amount_mist, ..
241            }
242            | Self::Agent { prepay_amount_mist } => prepay_amount_mist,
243        }
244    }
245}
246
247/// Behavior applied after an occurrence settles unsuccessfully.
248#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum FailurePolicy {
251    /// Continue advertising eligible work.
252    #[default]
253    Continue,
254    /// Pause the Task until its controller resumes it.
255    Pause,
256}
257
258/// A composable collection of standalone occurrences and one recurrence.
259#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
260pub struct Schedule {
261    occurrences: Vec<Occurrence>,
262    recurrence: Option<Recurrence>,
263}
264
265impl Schedule {
266    /// Creates an empty, valid Schedule.
267    pub const fn new() -> Self {
268        Self {
269            occurrences: Vec::new(),
270            recurrence: None,
271        }
272    }
273
274    /// Appends one standalone occurrence in deterministic insertion order.
275    #[must_use]
276    pub fn with_occurrence(mut self, occurrence: Occurrence) -> Self {
277        self.occurrences.push(occurrence);
278        self
279    }
280
281    /// Replaces the Schedule's optional recurrence.
282    #[must_use]
283    pub fn with_recurrence(mut self, recurrence: Recurrence) -> Self {
284        self.recurrence = Some(recurrence);
285        self
286    }
287
288    /// Returns whether the Schedule contains no work.
289    pub fn is_empty(&self) -> bool {
290        self.occurrences.is_empty() && self.recurrence.is_none()
291    }
292
293    /// Returns standalone occurrences in insertion order.
294    pub fn occurrences(&self) -> &[Occurrence] {
295        &self.occurrences
296    }
297
298    /// Returns the optional recurrence.
299    pub const fn recurrence(&self) -> Option<&Recurrence> {
300        self.recurrence.as_ref()
301    }
302
303    /// Verifies that the atomic create and schedule shortcut has work.
304    ///
305    /// Empty Schedules remain valid composition values; only the shortcut
306    /// requires at least one standalone occurrence or recurrence.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`ScheduleError::EmptySchedule`] when this Schedule is empty.
311    pub fn validate_for_task_creation(&self) -> Result<(), ScheduleError> {
312        self.validate()?;
313        if self.is_empty() {
314            Err(ScheduleError::EmptySchedule)
315        } else {
316            Ok(())
317        }
318    }
319
320    pub(crate) fn validate(&self) -> Result<(), ScheduleError> {
321        for occurrence in &self.occurrences {
322            occurrence.validate()?;
323        }
324        if let Some(recurrence) = &self.recurrence {
325            recurrence.validate()?;
326        }
327        Ok(())
328    }
329}
330
331/// One lazily expanded recurring source.
332#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
333pub struct Recurrence {
334    first: Occurrence,
335    interval_ms: u64,
336    occurrences: Option<u64>,
337}
338
339impl Recurrence {
340    /// Creates an unbounded recurrence.
341    ///
342    /// # Errors
343    ///
344    /// Returns [`ScheduleError::ZeroRecurrenceInterval`] when `interval_ms`
345    /// is zero.
346    pub fn new(first: Occurrence, interval_ms: u64) -> Result<Self, ScheduleError> {
347        let recurrence = Self {
348            first,
349            interval_ms,
350            occurrences: None,
351        };
352        recurrence.validate()?;
353        Ok(recurrence)
354    }
355
356    /// Removes a finite occurrence limit.
357    #[must_use]
358    pub const fn unbounded(mut self) -> Self {
359        self.occurrences = None;
360        self
361    }
362
363    /// Limits the recurrence to `occurrences` total materializations.
364    ///
365    /// # Errors
366    ///
367    /// Returns [`ScheduleError::ZeroRecurrenceCount`] when `occurrences` is
368    /// zero.
369    pub fn finite(mut self, occurrences: u64) -> Result<Self, ScheduleError> {
370        if occurrences == 0 {
371            return Err(ScheduleError::ZeroRecurrenceCount);
372        }
373        self.occurrences = Some(occurrences);
374        Ok(self)
375    }
376
377    /// Returns the first occurrence template.
378    pub const fn first(&self) -> &Occurrence {
379        &self.first
380    }
381
382    /// Returns the interval between materialized occurrences.
383    pub const fn interval_ms(&self) -> u64 {
384        self.interval_ms
385    }
386
387    /// Returns the optional total number of materializations.
388    pub const fn occurrences(&self) -> Option<u64> {
389        self.occurrences
390    }
391
392    pub(crate) fn validate(&self) -> Result<(), ScheduleError> {
393        self.first.validate()?;
394        if self.interval_ms == 0 {
395            return Err(ScheduleError::ZeroRecurrenceInterval);
396        }
397        if self.occurrences == Some(0) {
398            return Err(ScheduleError::ZeroRecurrenceCount);
399        }
400        Ok(())
401    }
402}
403
404#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(tag = "kind", rename_all = "snake_case")]
406pub(crate) enum StartTime {
407    Now,
408    At { timestamp_ms: u64 },
409    After { offset_ms: u64 },
410}
411
412#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(tag = "kind", rename_all = "snake_case")]
414pub(crate) enum Deadline {
415    At { timestamp_ms: u64 },
416    AfterStart { offset_ms: u64 },
417}
418
419/// Relative or absolute intent for one scheduled occurrence.
420#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
421pub struct Occurrence {
422    start: StartTime,
423    deadline: Option<Deadline>,
424    priority_fee_percentage: u64,
425}
426
427impl Occurrence {
428    /// Starts at the single chain time snapshot used to prepare its Schedule.
429    pub const fn now() -> Self {
430        Self {
431            start: StartTime::Now,
432            deadline: None,
433            priority_fee_percentage: DEFAULT_PRIORITY_FEE_PERCENTAGE,
434        }
435    }
436
437    /// Starts at an absolute millisecond timestamp.
438    pub const fn at_ms(timestamp_ms: u64) -> Self {
439        Self {
440            start: StartTime::At { timestamp_ms },
441            deadline: None,
442            priority_fee_percentage: DEFAULT_PRIORITY_FEE_PERCENTAGE,
443        }
444    }
445
446    /// Starts at an offset from the Schedule's single chain time snapshot.
447    pub const fn after_ms(offset_ms: u64) -> Self {
448        Self {
449            start: StartTime::After { offset_ms },
450            deadline: None,
451            priority_fee_percentage: DEFAULT_PRIORITY_FEE_PERCENTAGE,
452        }
453    }
454
455    /// Sets an absolute dispatch deadline.
456    ///
457    /// # Errors
458    ///
459    /// Returns [`ScheduleError::DeadlineBeforeStart`] when both timestamps are
460    /// absolute and `timestamp_ms` precedes the start. Starts relative to chain
461    /// time are checked during request preparation.
462    pub fn deadline_at_ms(mut self, timestamp_ms: u64) -> Result<Self, ScheduleError> {
463        if let StartTime::At {
464            timestamp_ms: start_time_ms,
465        } = self.start
466        {
467            if timestamp_ms < start_time_ms {
468                return Err(ScheduleError::DeadlineBeforeStart {
469                    start_time_ms,
470                    deadline_ms: timestamp_ms,
471                });
472            }
473        }
474        self.deadline = Some(Deadline::At { timestamp_ms });
475        Ok(self)
476    }
477
478    /// Sets the dispatch deadline to an offset from the resolved start.
479    #[must_use]
480    pub const fn deadline_after_ms(mut self, offset_ms: u64) -> Self {
481        self.deadline = Some(Deadline::AfterStart { offset_ms });
482        self
483    }
484
485    /// Sets the protocol priority fee percentage.
486    ///
487    /// # Errors
488    ///
489    /// Returns [`ScheduleError::PriorityFeeOutOfRange`] when `percentage` is
490    /// outside the inclusive protocol range.
491    pub fn with_priority_fee_percentage(mut self, percentage: u64) -> Result<Self, ScheduleError> {
492        if !(MIN_PRIORITY_FEE_PERCENTAGE..=MAX_PRIORITY_FEE_PERCENTAGE).contains(&percentage) {
493            return Err(ScheduleError::PriorityFeeOutOfRange {
494                percentage,
495                minimum: MIN_PRIORITY_FEE_PERCENTAGE,
496                maximum: MAX_PRIORITY_FEE_PERCENTAGE,
497            });
498        }
499        self.priority_fee_percentage = percentage;
500        Ok(self)
501    }
502
503    /// Returns the configured priority fee percentage.
504    pub const fn priority_fee_percentage(&self) -> u64 {
505        self.priority_fee_percentage
506    }
507
508    pub(crate) const fn start(&self) -> StartTime {
509        self.start
510    }
511
512    pub(crate) const fn deadline(&self) -> Option<Deadline> {
513        self.deadline
514    }
515
516    pub(crate) fn validate(&self) -> Result<(), ScheduleError> {
517        if !(MIN_PRIORITY_FEE_PERCENTAGE..=MAX_PRIORITY_FEE_PERCENTAGE)
518            .contains(&self.priority_fee_percentage)
519        {
520            return Err(ScheduleError::PriorityFeeOutOfRange {
521                percentage: self.priority_fee_percentage,
522                minimum: MIN_PRIORITY_FEE_PERCENTAGE,
523                maximum: MAX_PRIORITY_FEE_PERCENTAGE,
524            });
525        }
526        if let (
527            StartTime::At {
528                timestamp_ms: start_time_ms,
529            },
530            Some(Deadline::At {
531                timestamp_ms: deadline_ms,
532            }),
533        ) = (self.start, self.deadline)
534        {
535            if deadline_ms < start_time_ms {
536                return Err(ScheduleError::DeadlineBeforeStart {
537                    start_time_ms,
538                    deadline_ms,
539                });
540            }
541        }
542        Ok(())
543    }
544}
545
546/// Stable identity of one occurrence allocated by a Task.
547#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
548pub struct OccurrenceRef {
549    task_id: sui::types::Address,
550    occurrence_id: u64,
551}
552
553impl OccurrenceRef {
554    /// Creates an occurrence reference.
555    pub const fn new(task_id: sui::types::Address, occurrence_id: u64) -> Self {
556        Self {
557            task_id,
558            occurrence_id,
559        }
560    }
561
562    /// Returns the owning Task identifier.
563    pub const fn task_id(self) -> sui::types::Address {
564        self.task_id
565    }
566
567    /// Returns the Task local occurrence identifier.
568    pub const fn occurrence_id(self) -> u64 {
569        self.occurrence_id
570    }
571}
572
573/// One occurrence offered to the leader network for dispatch.
574#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
575pub struct DispatchOffer {
576    occurrence: OccurrenceRef,
577    effective_start_time_ms: u64,
578    deadline_ms: Option<u64>,
579    priority_fee_percentage: u64,
580}
581
582impl DispatchOffer {
583    /// Creates a validated dispatch offer at an ingestion boundary.
584    ///
585    /// # Errors
586    ///
587    /// Returns [`ScheduleError::DeadlineBeforeStart`] when the deadline
588    /// precedes the effective start, or
589    /// [`ScheduleError::PriorityFeeOutOfRange`] for an invalid fee.
590    pub fn new(
591        occurrence: OccurrenceRef,
592        effective_start_time_ms: u64,
593        deadline_ms: Option<u64>,
594        priority_fee_percentage: u64,
595    ) -> Result<Self, ScheduleError> {
596        if let Some(deadline_ms) = deadline_ms {
597            if deadline_ms < effective_start_time_ms {
598                return Err(ScheduleError::DeadlineBeforeStart {
599                    start_time_ms: effective_start_time_ms,
600                    deadline_ms,
601                });
602            }
603        }
604        if !(MIN_PRIORITY_FEE_PERCENTAGE..=MAX_PRIORITY_FEE_PERCENTAGE)
605            .contains(&priority_fee_percentage)
606        {
607            return Err(ScheduleError::PriorityFeeOutOfRange {
608                percentage: priority_fee_percentage,
609                minimum: MIN_PRIORITY_FEE_PERCENTAGE,
610                maximum: MAX_PRIORITY_FEE_PERCENTAGE,
611            });
612        }
613        Ok(Self {
614            occurrence,
615            effective_start_time_ms,
616            deadline_ms,
617            priority_fee_percentage,
618        })
619    }
620
621    /// Returns the occurrence being offered.
622    pub const fn occurrence(&self) -> OccurrenceRef {
623        self.occurrence
624    }
625
626    /// Returns the earliest protocol adjusted dispatch timestamp.
627    pub const fn effective_start_time_ms(&self) -> u64 {
628        self.effective_start_time_ms
629    }
630
631    /// Returns the optional absolute dispatch deadline.
632    pub const fn deadline_ms(&self) -> Option<u64> {
633        self.deadline_ms
634    }
635
636    /// Returns the dispatch priority fee percentage.
637    pub const fn priority_fee_percentage(&self) -> u64 {
638        self.priority_fee_percentage
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    fn address(value: &'static str) -> sui::types::Address {
647        sui::types::Address::from_static(value)
648    }
649
650    #[test]
651    fn empty_schedule_is_composable_but_not_a_creation_shortcut() {
652        let schedule = Schedule::new();
653
654        assert!(schedule.is_empty());
655        assert_eq!(
656            schedule.validate_for_task_creation(),
657            Err(ScheduleError::EmptySchedule)
658        );
659    }
660
661    #[test]
662    fn mixed_schedule_preserves_insertion_order_and_recurrence() {
663        let recurrence = Recurrence::new(Occurrence::now(), 1_000)
664            .expect("nonzero interval")
665            .finite(3)
666            .expect("nonzero count");
667        let mixed = Schedule::new()
668            .with_occurrence(Occurrence::now())
669            .with_occurrence(Occurrence::at_ms(2_000))
670            .with_recurrence(recurrence);
671
672        assert_eq!(mixed.occurrences().len(), 2);
673        assert!(mixed.recurrence().is_some());
674    }
675
676    #[test]
677    fn invalid_authoring_values_are_rejected() {
678        let address = sui::types::Address::from_static("0x1");
679        assert_eq!(
680            TaskSpec::new(
681                TaskOperation::default_dag(address),
682                "main",
683                TaskFunding::address(1),
684                0,
685            )
686            .expect_err("zero budget"),
687            ScheduleError::ZeroOccurrenceBudget
688        );
689        assert!(matches!(
690            Occurrence::at_ms(20).deadline_at_ms(10),
691            Err(ScheduleError::DeadlineBeforeStart { .. })
692        ));
693        assert!(matches!(
694            Occurrence::now().with_priority_fee_percentage(MAX_PRIORITY_FEE_PERCENTAGE + 1),
695            Err(ScheduleError::PriorityFeeOutOfRange { .. })
696        ));
697        assert_eq!(
698            Recurrence::new(Occurrence::now(), 0).expect_err("zero interval"),
699            ScheduleError::ZeroRecurrenceInterval
700        );
701        assert_eq!(
702            Recurrence::new(Occurrence::now(), 1)
703                .expect("valid recurrence")
704                .finite(0)
705                .expect_err("zero count"),
706            ScheduleError::ZeroRecurrenceCount
707        );
708    }
709
710    #[test]
711    fn task_spec_preserves_authored_operation_funding_and_inputs() {
712        let agent_id = address("0xa");
713        let selected_dag = address("0xb");
714        let recipient_id = address("0xc");
715        let authorization_bindings = BTreeMap::from([("summarize".to_owned(), recipient_id)]);
716
717        let operation =
718            TaskOperation::agent_skill(agent_id, 11, Some(selected_dag), authorization_bindings);
719        assert_eq!(operation.agent_id(), Some(agent_id));
720        assert_eq!(TaskOperation::default_dag(selected_dag).agent_id(), None);
721
722        let mut inputs = TaskInputs::new();
723        inputs.insert("summarize".to_owned(), BTreeMap::new());
724        let task = TaskSpec::new(operation, "main", TaskFunding::agent(90), 30)
725            .expect("Agent funding matches an Agent skill")
726            .with_inputs(inputs)
727            .with_failure_policy(FailurePolicy::Pause);
728
729        assert!(matches!(
730            task.operation(),
731            TaskOperation::AgentSkill {
732                agent_id: stored_agent,
733                skill_id: 11,
734                selected_dag: Some(stored_dag),
735                authorization_bindings,
736            } if *stored_agent == agent_id
737                && *stored_dag == selected_dag
738                && authorization_bindings.get("summarize") == Some(&recipient_id)
739        ));
740        assert_eq!(task.entry_group(), "main");
741        assert!(task.inputs().contains_key("summarize"));
742        assert_eq!(task.funding(), TaskFunding::agent(90));
743        assert_eq!(task.funding().prepay_amount_mist(), 90);
744        assert_eq!(task.occurrence_budget_mist(), 30);
745        assert_eq!(task.failure_policy(), FailurePolicy::Pause);
746    }
747
748    #[test]
749    fn task_authoring_rejects_blank_names_and_incompatible_funding() {
750        let dag_id = address("0xd");
751        assert_eq!(
752            TaskSpec::new(
753                TaskOperation::default_dag(dag_id),
754                "  ",
755                TaskFunding::address(1),
756                1,
757            )
758            .expect_err("blank entry group"),
759            ScheduleError::EmptyEntryGroup
760        );
761        assert!(matches!(
762            TaskSpec::new(
763                TaskOperation::default_dag(dag_id),
764                "main",
765                TaskFunding::agent(1),
766                1,
767            ),
768            Err(ScheduleError::IncompatibleFunding { .. })
769        ));
770
771        let refund_recipient = address("0xe");
772        let funding = TaskFunding::address_with_refund(40, refund_recipient);
773        assert_eq!(funding.prepay_amount_mist(), 40);
774        assert!(matches!(
775            funding,
776            TaskFunding::Address {
777                prepay_amount_mist: 40,
778                refund_recipient: Some(stored_recipient),
779            } if stored_recipient == refund_recipient
780        ));
781        assert_eq!(TaskFunding::address(20).prepay_amount_mist(), 20);
782    }
783
784    #[test]
785    fn schedule_composition_preserves_every_time_form() {
786        let absolute = Occurrence::at_ms(100)
787            .deadline_at_ms(120)
788            .expect("deadline follows start")
789            .with_priority_fee_percentage(MIN_PRIORITY_FEE_PERCENTAGE)
790            .expect("minimum fee is valid");
791        assert_eq!(absolute.start(), StartTime::At { timestamp_ms: 100 });
792        assert_eq!(
793            absolute.deadline(),
794            Some(Deadline::At { timestamp_ms: 120 })
795        );
796        assert_eq!(
797            absolute.priority_fee_percentage(),
798            MIN_PRIORITY_FEE_PERCENTAGE
799        );
800
801        let relative = Occurrence::after_ms(10).deadline_after_ms(5);
802        assert_eq!(relative.start(), StartTime::After { offset_ms: 10 });
803        assert_eq!(
804            relative.deadline(),
805            Some(Deadline::AfterStart { offset_ms: 5 })
806        );
807        assert_eq!(Occurrence::now().start(), StartTime::Now);
808
809        let recurrence = Recurrence::new(relative, 25)
810            .expect("interval advances time")
811            .finite(3)
812            .expect("finite recurrence is nonempty");
813        assert_eq!(recurrence.first(), &relative);
814        assert_eq!(recurrence.interval_ms(), 25);
815        assert_eq!(recurrence.occurrences(), Some(3));
816        assert_eq!(recurrence.clone().unbounded().occurrences(), None);
817
818        let schedule = Schedule::new()
819            .with_occurrence(absolute)
820            .with_recurrence(recurrence);
821        assert!(!schedule.is_empty());
822        assert_eq!(schedule.occurrences(), &[absolute]);
823        assert!(schedule.recurrence().is_some());
824        assert_eq!(schedule.validate_for_task_creation(), Ok(()));
825    }
826
827    #[test]
828    fn nested_schedule_validation_rejects_invalid_stored_values() {
829        let invalid_priority = Occurrence {
830            start: StartTime::Now,
831            deadline: None,
832            priority_fee_percentage: MAX_PRIORITY_FEE_PERCENTAGE + 1,
833        };
834        assert!(matches!(
835            Schedule::new().with_occurrence(invalid_priority).validate(),
836            Err(ScheduleError::PriorityFeeOutOfRange { .. })
837        ));
838
839        let invalid_deadline = Occurrence {
840            start: StartTime::At { timestamp_ms: 20 },
841            deadline: Some(Deadline::At { timestamp_ms: 10 }),
842            priority_fee_percentage: DEFAULT_PRIORITY_FEE_PERCENTAGE,
843        };
844        assert!(matches!(
845            invalid_deadline.validate(),
846            Err(ScheduleError::DeadlineBeforeStart { .. })
847        ));
848
849        let invalid_recurrence = Recurrence {
850            first: Occurrence::now(),
851            interval_ms: 1,
852            occurrences: Some(0),
853        };
854        assert_eq!(
855            invalid_recurrence.validate(),
856            Err(ScheduleError::ZeroRecurrenceCount)
857        );
858        assert_eq!(
859            Schedule::new()
860                .with_recurrence(invalid_recurrence)
861                .validate(),
862            Err(ScheduleError::ZeroRecurrenceCount)
863        );
864    }
865
866    #[test]
867    fn dispatch_offer_validates_and_exposes_protocol_values() {
868        let reference = OccurrenceRef::new(address("0xf"), 9);
869        assert_eq!(reference.task_id(), address("0xf"));
870        assert_eq!(reference.occurrence_id(), 9);
871
872        let offer = DispatchOffer::new(reference, 100, Some(120), MAX_PRIORITY_FEE_PERCENTAGE)
873            .expect("offer values are valid");
874        assert_eq!(offer.occurrence(), reference);
875        assert_eq!(offer.effective_start_time_ms(), 100);
876        assert_eq!(offer.deadline_ms(), Some(120));
877        assert_eq!(offer.priority_fee_percentage(), MAX_PRIORITY_FEE_PERCENTAGE);
878
879        assert!(matches!(
880            DispatchOffer::new(reference, 100, Some(99), DEFAULT_PRIORITY_FEE_PERCENTAGE),
881            Err(ScheduleError::DeadlineBeforeStart { .. })
882        ));
883        assert!(matches!(
884            DispatchOffer::new(reference, 100, None, MIN_PRIORITY_FEE_PERCENTAGE - 1),
885            Err(ScheduleError::PriorityFeeOutOfRange { .. })
886        ));
887    }
888}