1use super::{
2 Budget, CheckpointId, Duration, Error, Future, NonZeroU32, NonZeroU64, Pin, Usage, Value,
3 WorkflowInterruptRequest, WorkflowLineage, WorkflowWait, WorkflowWake,
4};
5
6pub type WorkflowStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
8
9#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
11pub struct WorkflowTenantId(String);
12
13impl WorkflowTenantId {
14 pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowStoreError> {
20 let value = value.into();
21 if value.is_empty()
22 || value.len() > 128
23 || !value
24 .bytes()
25 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
26 {
27 return Err(WorkflowStoreError::invalid_input(
28 "workflow tenant must contain 1..=128 portable ASCII characters",
29 ));
30 }
31 Ok(Self(value))
32 }
33
34 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl Default for WorkflowTenantId {
41 fn default() -> Self {
42 Self("default".into())
43 }
44}
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48pub struct WorkflowTenantPolicy {
49 max_outstanding_tasks: NonZeroU32,
50 max_concurrent_leases: NonZeroU32,
51}
52
53impl WorkflowTenantPolicy {
54 pub fn new(
60 max_outstanding_tasks: u32,
61 max_concurrent_leases: u32,
62 ) -> Result<Self, WorkflowStoreError> {
63 let max_outstanding_tasks = NonZeroU32::new(max_outstanding_tasks).ok_or_else(|| {
64 WorkflowStoreError::invalid_input("tenant outstanding workflow limit must be positive")
65 })?;
66 let max_concurrent_leases = NonZeroU32::new(max_concurrent_leases).ok_or_else(|| {
67 WorkflowStoreError::invalid_input(
68 "tenant concurrent workflow lease limit must be positive",
69 )
70 })?;
71 if max_concurrent_leases > max_outstanding_tasks {
72 return Err(WorkflowStoreError::invalid_input(
73 "tenant concurrent workflow lease limit cannot exceed outstanding limit",
74 ));
75 }
76 Ok(Self {
77 max_outstanding_tasks,
78 max_concurrent_leases,
79 })
80 }
81
82 pub const fn max_outstanding_tasks(self) -> u32 {
84 self.max_outstanding_tasks.get()
85 }
86
87 pub const fn max_concurrent_leases(self) -> u32 {
89 self.max_concurrent_leases.get()
90 }
91}
92
93impl Default for WorkflowTenantPolicy {
94 fn default() -> Self {
95 Self {
96 max_outstanding_tasks: NonZeroU32::new(10_000)
97 .expect("default outstanding limit is positive"),
98 max_concurrent_leases: NonZeroU32::new(100)
99 .expect("default concurrent lease limit is positive"),
100 }
101 }
102}
103
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
106pub struct WorkflowTenantListLimit(NonZeroU32);
107
108impl WorkflowTenantListLimit {
109 pub fn new(value: u32) -> Result<Self, WorkflowStoreError> {
115 let value = NonZeroU32::new(value).ok_or_else(|| {
116 WorkflowStoreError::invalid_input("workflow tenant list limit must be positive")
117 })?;
118 if value.get() > 1_000 {
119 return Err(WorkflowStoreError::invalid_input(
120 "workflow tenant list limit cannot exceed 1,000",
121 ));
122 }
123 Ok(Self(value))
124 }
125
126 pub const fn get(self) -> u32 {
128 self.0.get()
129 }
130}
131
132impl Default for WorkflowTenantListLimit {
133 fn default() -> Self {
134 Self(NonZeroU32::new(100).expect("default tenant page size is positive"))
135 }
136}
137
138#[derive(Clone, Copy, Debug, Eq, PartialEq)]
140pub struct WorkflowTenantBudgetPolicy {
141 limit: Budget,
142 window_ms: NonZeroU64,
143 recovery_grace_ms: u64,
144}
145
146impl WorkflowTenantBudgetPolicy {
147 pub fn new(
154 limit: Budget,
155 window: Duration,
156 recovery_grace: Duration,
157 ) -> Result<Self, WorkflowStoreError> {
158 if budget_is_unbounded(limit) {
159 return Err(WorkflowStoreError::invalid_input(
160 "tenant budget policy must limit at least one resource",
161 ));
162 }
163 validate_budget_duration(limit)?;
164 let window_ms = u64::try_from(window.as_millis())
165 .ok()
166 .and_then(NonZeroU64::new)
167 .ok_or_else(|| {
168 WorkflowStoreError::invalid_input(
169 "tenant budget window must fit in positive whole milliseconds",
170 )
171 })?;
172 let recovery_grace_ms = u64::try_from(recovery_grace.as_millis()).map_err(|_| {
173 WorkflowStoreError::invalid_input(
174 "tenant budget recovery grace exceeds supported milliseconds",
175 )
176 })?;
177 Ok(Self {
178 limit,
179 window_ms,
180 recovery_grace_ms,
181 })
182 }
183
184 pub const fn limit(self) -> Budget {
186 self.limit
187 }
188
189 pub const fn window_millis(self) -> u64 {
191 self.window_ms.get()
192 }
193
194 pub const fn recovery_grace_millis(self) -> u64 {
196 self.recovery_grace_ms
197 }
198}
199
200#[derive(Clone, Debug, Eq, PartialEq)]
202pub struct WorkflowTenantBudgetSnapshot {
203 pub tenant_id: WorkflowTenantId,
205 pub limit: Budget,
207 pub window_started_at_ms: u64,
209 pub committed: Usage,
211 pub reserved: Usage,
213 pub active_reservations: u64,
215}
216
217#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
219pub struct WorkflowBudgetAuditCursor(u64);
220
221impl WorkflowBudgetAuditCursor {
222 pub const fn new(sequence: u64) -> Self {
224 Self(sequence)
225 }
226
227 pub const fn sequence(self) -> u64 {
229 self.0
230 }
231}
232
233#[derive(Clone, Copy, Debug, Eq, PartialEq)]
235pub struct WorkflowBudgetAuditLimit(NonZeroU32);
236
237impl WorkflowBudgetAuditLimit {
238 pub fn new(value: u32) -> Result<Self, WorkflowStoreError> {
244 let value = NonZeroU32::new(value).ok_or_else(|| {
245 WorkflowStoreError::invalid_input("workflow budget audit limit must be positive")
246 })?;
247 if value.get() > 1_000 {
248 return Err(WorkflowStoreError::invalid_input(
249 "workflow budget audit limit cannot exceed 1,000",
250 ));
251 }
252 Ok(Self(value))
253 }
254
255 pub const fn get(self) -> u32 {
257 self.0.get()
258 }
259}
260
261impl Default for WorkflowBudgetAuditLimit {
262 fn default() -> Self {
263 Self(NonZeroU32::new(100).expect("default audit page size is positive"))
264 }
265}
266
267#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
269pub struct WorkflowBudgetAuditProjectionId(String);
270
271impl WorkflowBudgetAuditProjectionId {
272 pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowStoreError> {
278 let value = value.into();
279 if value.is_empty()
280 || value.len() > 128
281 || !value
282 .bytes()
283 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
284 {
285 return Err(WorkflowStoreError::invalid_input(
286 "workflow budget audit projection must contain 1..=128 portable ASCII characters",
287 ));
288 }
289 Ok(Self(value))
290 }
291
292 pub fn as_str(&self) -> &str {
294 &self.0
295 }
296}
297
298#[derive(Clone, Debug, Eq, PartialEq)]
300pub struct WorkflowBudgetAuditProjectionLease {
301 pub tenant_id: WorkflowTenantId,
303 pub projection_id: WorkflowBudgetAuditProjectionId,
305 pub owner: WorkerId,
307 pub cursor: WorkflowBudgetAuditCursor,
309 pub fencing_token: u64,
311 pub expires_at_ms: u64,
313}
314
315impl WorkflowBudgetAuditProjectionLease {
316 pub fn tenant_id(&self) -> &WorkflowTenantId {
318 &self.tenant_id
319 }
320
321 pub fn projection_id(&self) -> &WorkflowBudgetAuditProjectionId {
323 &self.projection_id
324 }
325
326 pub fn owner(&self) -> &WorkerId {
328 &self.owner
329 }
330
331 pub const fn cursor(&self) -> WorkflowBudgetAuditCursor {
333 self.cursor
334 }
335
336 pub const fn fencing_token(&self) -> u64 {
338 self.fencing_token
339 }
340
341 pub const fn expires_at_ms(&self) -> u64 {
343 self.expires_at_ms
344 }
345}
346
347#[derive(Clone, Copy, Debug, Eq, PartialEq)]
349#[non_exhaustive]
350pub enum WorkflowBudgetForfeitReason {
351 Cancelled,
353 RecoveryExpired,
355}
356
357#[derive(Clone, Copy, Debug, Eq, PartialEq)]
359#[non_exhaustive]
360pub enum WorkflowBudgetAuditKind {
361 PolicyConfigured,
363 Reserved,
365 Adopted,
367 AdmissionDenied,
369 UsageExceeded,
371 Settled,
373 Forfeited(WorkflowBudgetForfeitReason),
375 WindowReset,
377}
378
379#[derive(Clone, Debug, Eq, PartialEq)]
381pub struct WorkflowBudgetAuditEvent {
382 pub cursor: WorkflowBudgetAuditCursor,
384 pub tenant_id: WorkflowTenantId,
386 pub checkpoint_id: Option<CheckpointId>,
388 pub occurred_at_ms: u64,
390 pub kind: WorkflowBudgetAuditKind,
392 pub usage: Usage,
394 pub reservation_age_ms: Option<u64>,
396 pub limit: Budget,
398 pub committed: Usage,
400 pub reserved: Usage,
402}
403
404#[derive(Clone, Copy, Debug, Eq, PartialEq)]
406#[non_exhaustive]
407pub enum WorkflowBudgetReservationOutcome {
408 NotConfigured,
410 Reserved,
412}
413
414#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
416pub struct WorkerId(String);
417
418impl WorkerId {
419 pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowStoreError> {
425 let value = value.into();
426 if value.is_empty()
427 || value.len() > 128
428 || !value
429 .bytes()
430 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
431 {
432 return Err(WorkflowStoreError::invalid_input(
433 "worker identity must contain 1..=128 portable ASCII characters",
434 ));
435 }
436 Ok(Self(value))
437 }
438
439 pub fn as_str(&self) -> &str {
441 &self.0
442 }
443}
444
445#[derive(Clone, Copy, Debug, Eq, PartialEq)]
447pub struct LeaseDuration(NonZeroU64);
448
449impl LeaseDuration {
450 pub fn new(duration: Duration) -> Result<Self, WorkflowStoreError> {
456 let millis = u64::try_from(duration.as_millis())
457 .ok()
458 .and_then(NonZeroU64::new)
459 .ok_or_else(|| {
460 WorkflowStoreError::invalid_input(
461 "workflow lease must fit in a positive whole-millisecond duration",
462 )
463 })?;
464 Ok(Self(millis))
465 }
466
467 pub const fn as_millis(self) -> u64 {
469 self.0.get()
470 }
471}
472
473#[derive(Clone, Debug, PartialEq)]
475pub struct WorkflowTask {
476 pub checkpoint_id: CheckpointId,
478 pub tenant_id: WorkflowTenantId,
480 pub workflow: String,
482 pub workflow_version: u32,
484 pub input: Value,
486 pub priority: i32,
488}
489
490impl WorkflowTask {
491 pub fn new(
497 workflow: impl Into<String>,
498 workflow_version: u32,
499 input: Value,
500 ) -> Result<Self, WorkflowStoreError> {
501 let workflow = workflow.into();
502 if workflow.trim().is_empty() || workflow.len() > 256 {
503 return Err(WorkflowStoreError::invalid_input(
504 "workflow name must contain 1..=256 bytes",
505 ));
506 }
507 if workflow_version == 0 {
508 return Err(WorkflowStoreError::invalid_input(
509 "workflow version must be greater than zero",
510 ));
511 }
512 Ok(Self {
513 checkpoint_id: CheckpointId::new(),
514 tenant_id: WorkflowTenantId::default(),
515 workflow,
516 workflow_version,
517 input,
518 priority: 0,
519 })
520 }
521
522 #[must_use]
524 pub fn with_tenant(mut self, tenant_id: WorkflowTenantId) -> Self {
525 self.tenant_id = tenant_id;
526 self
527 }
528
529 #[must_use]
531 pub const fn with_checkpoint_id(mut self, checkpoint_id: CheckpointId) -> Self {
532 self.checkpoint_id = checkpoint_id;
533 self
534 }
535
536 #[must_use]
538 pub const fn with_priority(mut self, priority: i32) -> Self {
539 self.priority = priority;
540 self
541 }
542
543 pub fn validate(&self) -> Result<(), WorkflowStoreError> {
549 if self.workflow.trim().is_empty() || self.workflow.len() > 256 {
550 return Err(WorkflowStoreError::invalid_input(
551 "workflow name must contain 1..=256 bytes",
552 ));
553 }
554 if self.workflow_version == 0 {
555 return Err(WorkflowStoreError::invalid_input(
556 "workflow version must be greater than zero",
557 ));
558 }
559 Ok(())
560 }
561}
562
563#[derive(Clone, Debug, Eq, PartialEq)]
565pub struct WorkflowLease {
566 pub checkpoint_id: CheckpointId,
568 pub tenant_id: WorkflowTenantId,
570 pub worker: WorkerId,
572 pub fencing_token: u64,
574 pub attempt: u64,
576 pub expires_at_ms: u64,
578}
579
580#[derive(Clone, Debug, PartialEq)]
582pub struct ClaimedWorkflow {
583 pub task: WorkflowTask,
585 pub lease: WorkflowLease,
587 pub wake: Option<WorkflowWake>,
589}
590
591#[derive(Clone, Debug, Eq, PartialEq)]
593#[non_exhaustive]
594pub enum WorkflowDisposition {
595 Completed,
597 RetryAfter(Duration),
599 Suspend(WorkflowWait),
601 Failed(String),
603 Cancelled,
605}
606
607#[derive(Clone, Copy, Debug, Eq, PartialEq)]
609#[non_exhaustive]
610pub enum WorkflowTaskStatus {
611 Queued,
613 Leased,
615 Waiting,
617 Completed,
619 Failed,
621 Cancelled,
623}
624
625#[derive(Clone, Copy, Debug, Eq, PartialEq)]
627pub struct WorkflowTaskRetention(NonZeroU64);
628
629impl WorkflowTaskRetention {
630 pub fn new(duration: Duration) -> Result<Self, WorkflowStoreError> {
636 let millis = u64::try_from(duration.as_millis())
637 .ok()
638 .and_then(NonZeroU64::new)
639 .ok_or_else(|| {
640 WorkflowStoreError::invalid_input(
641 "workflow Task retention must fit in positive whole milliseconds",
642 )
643 })?;
644 Ok(Self(millis))
645 }
646
647 pub const fn as_millis(self) -> u64 {
649 self.0.get()
650 }
651}
652
653#[derive(Clone, Copy, Debug, Eq, PartialEq)]
655pub struct WorkflowTaskCleanupLimit(NonZeroU32);
656
657impl WorkflowTaskCleanupLimit {
658 pub fn new(value: u32) -> Result<Self, WorkflowStoreError> {
664 let value = NonZeroU32::new(value).ok_or_else(|| {
665 WorkflowStoreError::invalid_input("workflow Task cleanup limit must be positive")
666 })?;
667 if value.get() > 1_000 {
668 return Err(WorkflowStoreError::invalid_input(
669 "workflow Task cleanup limit cannot exceed 1,000",
670 ));
671 }
672 Ok(Self(value))
673 }
674
675 pub const fn get(self) -> u32 {
677 self.0.get()
678 }
679}
680
681#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
683pub struct WorkflowTaskTombstoneCursor(u64);
684
685impl WorkflowTaskTombstoneCursor {
686 pub const fn new(sequence: u64) -> Self {
688 Self(sequence)
689 }
690
691 pub const fn get(self) -> u64 {
693 self.0
694 }
695}
696
697#[derive(Clone, Copy, Debug, Eq, PartialEq)]
699pub struct WorkflowTaskTombstoneLimit(NonZeroU32);
700
701impl WorkflowTaskTombstoneLimit {
702 pub fn new(value: u32) -> Result<Self, WorkflowStoreError> {
708 let value = NonZeroU32::new(value).ok_or_else(|| {
709 WorkflowStoreError::invalid_input("workflow Task tombstone limit must be positive")
710 })?;
711 if value.get() > 1_000 {
712 return Err(WorkflowStoreError::invalid_input(
713 "workflow Task tombstone limit cannot exceed 1,000",
714 ));
715 }
716 Ok(Self(value))
717 }
718
719 pub const fn get(self) -> u32 {
721 self.0.get()
722 }
723}
724
725#[derive(Clone, Debug, Eq, PartialEq)]
727pub struct WorkflowTaskCleanupLease {
728 pub tenant_id: WorkflowTenantId,
730 pub owner: WorkerId,
732 pub fencing_token: u64,
734 pub expires_at_ms: u64,
736}
737
738#[derive(Clone, Debug, Eq, PartialEq)]
740pub struct WorkflowTaskTombstone {
741 pub cursor: WorkflowTaskTombstoneCursor,
743 pub checkpoint_id: CheckpointId,
745 pub tenant_id: WorkflowTenantId,
747 pub workflow: String,
749 pub workflow_version: u32,
751 pub final_status: WorkflowTaskStatus,
753 pub created_at_ms: u64,
755 pub terminal_at_ms: u64,
757 pub deleted_at_ms: u64,
759}
760
761#[derive(Clone, Copy, Debug, Eq, PartialEq)]
763#[non_exhaustive]
764pub enum WorkflowCancelOutcome {
765 Cancelled,
767 AlreadyTerminal,
769}
770
771#[derive(Clone, Debug, Eq, PartialEq)]
773pub struct WorkflowTaskSnapshot {
774 pub checkpoint_id: CheckpointId,
776 pub tenant_id: WorkflowTenantId,
778 pub workflow: String,
780 pub workflow_version: u32,
782 pub status: WorkflowTaskStatus,
784 pub created_at_ms: u64,
786 pub updated_at_ms: u64,
788 pub attempts: u64,
790 pub fencing_token: u64,
792 pub owner: Option<WorkerId>,
794 pub lease_expires_at_ms: Option<u64>,
796 pub interrupt: Option<WorkflowInterruptRequest>,
798 pub failure_message: Option<String>,
800 pub lineage: Option<WorkflowLineage>,
802}
803
804#[derive(Clone, Copy, Debug, Eq, PartialEq)]
806#[non_exhaustive]
807pub enum WorkflowStoreErrorKind {
808 InvalidInput,
810 NotFound,
812 Conflict,
814 LeaseLost,
816 AdmissionDenied,
818 TenantMismatch,
820 Storage,
822}
823
824#[derive(Clone, Debug, Error, Eq, PartialEq)]
826#[error("{kind:?}: {message}")]
827pub struct WorkflowStoreError {
828 pub kind: WorkflowStoreErrorKind,
830 pub message: String,
832}
833
834impl WorkflowStoreError {
835 pub fn new(kind: WorkflowStoreErrorKind, message: impl Into<String>) -> Self {
837 Self {
838 kind,
839 message: message.into(),
840 }
841 }
842
843 pub(super) fn invalid_input(message: impl Into<String>) -> Self {
844 Self::new(WorkflowStoreErrorKind::InvalidInput, message)
845 }
846}
847
848fn budget_is_unbounded(budget: Budget) -> bool {
849 budget.tokens.is_none()
850 && budget.cost_microusd.is_none()
851 && budget.duration.is_none()
852 && budget.turns.is_none()
853 && budget.tool_calls.is_none()
854 && budget.delegations.is_none()
855}
856
857fn validate_budget_duration(budget: Budget) -> Result<(), WorkflowStoreError> {
858 if let Some(duration) = budget.duration {
859 duration_micros(duration)?;
860 }
861 Ok(())
862}
863
864pub(super) fn duration_micros(duration: Duration) -> Result<u64, WorkflowStoreError> {
865 u64::try_from(duration.as_micros()).map_err(|_| {
866 WorkflowStoreError::invalid_input("budget duration exceeds supported microseconds")
867 })
868}