1use crate::*;
7use ledgence_worker_api::{ProgramDescriptor, ProgramRef, validate_wire_value};
8use serde_json::Value;
9use std::collections::{BTreeMap, BTreeSet};
10
11pub const WORKFLOW_RUNTIME_SCHEMA: &str = "ledgence.workflow.activation.v1";
12pub const WORKFLOW_VERSION: u8 = 1;
13pub const WORKFLOW_MAX_COMMANDS: usize = 64;
14pub const WORKFLOW_MAX_LOCAL_STEPS: usize = 128;
15pub const WORKFLOW_CHECKPOINT_MAX_BYTES: usize = 64 * 1024;
16pub const WORKFLOW_DECISION_MAX_BYTES: usize = 256 * 1024;
17pub const WORKFLOW_INPUTS_MAX_BYTES: usize = 256 * 1024;
18pub const WORKFLOW_LOCAL_RECORD_MAX_BYTES: usize = 128 * 1024;
19pub const WORKFLOW_LOCAL_LEDGER_MAX_BYTES: usize = 256 * 1024;
20pub const WORKFLOW_CONTEXT_MAX_BYTES: usize = 640 * 1024;
21pub const WORKFLOW_MAX_WORK_BATCH: u32 = 16;
22
23pub fn validate_task_output(output: &Value, workflow_activation: bool) -> Result<()> {
29 if workflow_activation {
30 ledgence_worker_api::validate_runtime_payload(output, SETTLEMENT_MAX_BYTES)?;
31 } else {
32 validate_wire_value(output)?;
33 }
34 Ok(())
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct LocalStepRecord {
40 pub key: String,
41 pub callable: String,
42 #[serde(deserialize_with = "crate::observation::required_value")]
43 pub input: Value,
44 #[serde(deserialize_with = "crate::observation::required_value")]
45 pub output: Value,
46}
47impl LocalStepRecord {
48 pub fn validate(&self) -> Result<()> {
49 validate_text(&self.key, 128)?;
50 validate_text(&self.callable, 512)?;
51 validate_wire_value(&self.input)?;
52 validate_wire_value(&self.output)?;
53 bounded(self, WORKFLOW_LOCAL_RECORD_MAX_BYTES, "local step")
54 }
55
56 pub fn matches(&self, other: &Self) -> Result<bool> {
59 self.validate()?;
60 other.validate()?;
61 Ok(
62 canonical_json_bytes(&serde_json::to_value(self).map_err(encoding)?)?
63 == canonical_json_bytes(&serde_json::to_value(other).map_err(encoding)?)?,
64 )
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct WorkflowActivationContext {
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub parent_workflow_id: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub root_workflow_id: Option<String>,
78 pub v: u8,
79 pub workflow_id: String,
80 pub activation_id: String,
81 pub revision: u64,
82 pub continuation: String,
83 #[serde(deserialize_with = "crate::observation::required_value")]
84 pub state: Value,
85 pub inputs: BTreeMap<String, WorkflowChildResult>,
86 pub local_steps: Vec<LocalStepRecord>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub wake: Option<WorkflowWake>,
91}
92impl WorkflowActivationContext {
93 pub fn validate(&self) -> Result<()> {
94 version(self.v)?;
95 validate_workflow_lineage(
96 Some(&self.workflow_id),
97 self.parent_workflow_id.as_deref(),
98 self.root_workflow_id.as_deref(),
99 )?;
100 validate_text(&self.activation_id, 128)?;
101 validate_text(&self.continuation, 128)?;
102 checkpoint(&self.state)?;
103 if self.inputs.len() > WORKFLOW_MAX_COMMANDS
104 || self.local_steps.len() > WORKFLOW_MAX_LOCAL_STEPS
105 {
106 return Err(invalid("workflow context has too many entries"));
107 }
108 for (key, child) in &self.inputs {
109 validate_text(key, 128)?;
110 child.validate()?;
111 }
112 if let Some(wake) = &self.wake {
113 wake.validate()?;
114 #[derive(Serialize)]
115 struct FrozenInputs<'a> {
116 inputs: &'a BTreeMap<String, WorkflowChildResult>,
117 wake: &'a WorkflowWake,
118 }
119 bounded(
120 &FrozenInputs {
121 inputs: &self.inputs,
122 wake,
123 },
124 WORKFLOW_INPUTS_MAX_BYTES,
125 "workflow inputs and wake",
126 )?;
127 } else {
128 bounded(&self.inputs, WORKFLOW_INPUTS_MAX_BYTES, "workflow inputs")?;
129 }
130 let mut keys = BTreeSet::new();
131 for step in &self.local_steps {
132 step.validate()?;
133 if !keys.insert(&step.key) {
134 return Err(invalid("duplicate local step key"));
135 }
136 }
137 bounded(
138 &self.local_steps,
139 WORKFLOW_LOCAL_LEDGER_MAX_BYTES,
140 "local step ledger",
141 )?;
142 bounded(self, WORKFLOW_CONTEXT_MAX_BYTES, "workflow context")
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct WorkflowChildCommand {
149 #[serde(default, skip_serializing_if = "WorkflowChildKind::is_task")]
150 pub kind: WorkflowChildKind,
151 pub key: String,
152 pub program: ProgramRef,
153 pub queue: String,
154 #[serde(deserialize_with = "crate::observation::required_value")]
155 pub data: Value,
156 #[serde(default)]
157 pub retry_policy: RetryPolicy,
158 #[serde(default = "attempt_timeout")]
159 pub attempt_timeout_ms: u64,
160}
161const fn attempt_timeout() -> u64 {
162 300_000
163}
164pub type WorkflowTaskCommand = WorkflowChildCommand;
166
167impl WorkflowChildCommand {
168 pub fn submission(&self, scope: &Scope, correlation_key: Option<String>) -> SubmitTask {
169 SubmitTask {
170 tenant_id: scope.tenant_id.clone(),
171 namespace: scope.namespace.clone(),
172 queue: self.queue.clone(),
173 program: self.program.clone(),
174 correlation_key,
175 data: self.data.clone(),
176 retry_policy: self.retry_policy.clone(),
177 attempt_timeout_ms: self.attempt_timeout_ms,
178 }
179 }
180 pub fn validate(&self) -> Result<()> {
181 validate_text(&self.key, 128)?;
182 self.submission(
183 &Scope {
184 tenant_id: "validation".into(),
185 namespace: "validation".into(),
186 },
187 None,
188 )
189 .validate()?;
190 Ok(())
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct WorkflowDecision {
196 pub v: u8,
197 pub activation_id: String,
198 pub revision: u64,
199 #[serde(flatten)]
200 pub action: WorkflowAction,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(tag = "kind", rename_all = "snake_case")]
205pub enum WorkflowAction {
206 Wait {
207 state: Value,
208 continuation: String,
209 commands: Vec<WorkflowChildCommand>,
210 wait: WorkflowWait,
211 },
212 Suspend {
213 state: Value,
214 continuation: String,
215 commands: Vec<WorkflowChildCommand>,
216 until: Vec<String>,
218 },
219 Continue {
220 state: Value,
221 continuation: String,
222 commands: Vec<WorkflowChildCommand>,
223 },
224 Complete {
225 output: Value,
226 },
227 Fail {
228 error: ApplicationError,
229 },
230}
231impl WorkflowDecision {
232 pub fn decode(value: &Value) -> Result<Self> {
235 bounded(value, WORKFLOW_DECISION_MAX_BYTES, "workflow decision")?;
237 let object = value
238 .as_object()
239 .ok_or_else(|| invalid("workflow decision must be an object"))?;
240 let action_fields: &[&str] = match object.get("kind").and_then(Value::as_str) {
241 Some("wait") => &["state", "continuation", "commands", "wait"],
242 Some("suspend") => &["state", "continuation", "commands", "until"],
243 Some("continue") => &["state", "continuation", "commands"],
244 Some("complete") => &["output"],
245 Some("fail") => &["error"],
246 _ => return Err(invalid("unknown workflow decision kind")),
247 };
248 let common = ["v", "activation_id", "revision", "kind"];
249 if object.len() != common.len() + action_fields.len()
250 || object.keys().any(|key| {
251 !common.contains(&key.as_str()) && !action_fields.contains(&key.as_str())
252 })
253 {
254 return Err(invalid("unknown or missing workflow decision fields"));
255 }
256 let decision: Self = serde_json::from_value(value.clone()).map_err(encoding)?;
257 decision.validate()?;
258 Ok(decision)
259 }
260 pub fn validate(&self) -> Result<()> {
261 version(self.v)?;
262 validate_text(&self.activation_id, 128)?;
263 match &self.action {
264 WorkflowAction::Wait {
265 state,
266 continuation,
267 commands,
268 wait,
269 } => {
270 validate_continuation(state, continuation, commands)?;
271 wait.validate()?;
272 }
273 WorkflowAction::Suspend {
274 state,
275 continuation,
276 commands,
277 until,
278 } => {
279 validate_continuation(state, continuation, commands)?;
280 if until.len() > WORKFLOW_MAX_COMMANDS {
281 return Err(invalid("wait membership exceeds supported limit"));
282 }
283 let mut keys = BTreeSet::new();
284 for key in until {
285 validate_text(key, 128)?;
286 if !keys.insert(key) {
287 return Err(invalid("duplicate wait member"));
288 }
289 }
290 }
291 WorkflowAction::Continue {
292 state,
293 continuation,
294 commands,
295 } => validate_continuation(state, continuation, commands)?,
296 WorkflowAction::Complete { output } => validate_wire_value(output)?,
297 WorkflowAction::Fail { error } => validate_error(error)?,
298 }
299 bounded(self, WORKFLOW_DECISION_MAX_BYTES, "workflow decision")
300 }
301 pub fn commands(&self) -> &[WorkflowChildCommand] {
302 match &self.action {
303 WorkflowAction::Wait { commands, .. }
304 | WorkflowAction::Suspend { commands, .. }
305 | WorkflowAction::Continue { commands, .. } => commands,
306 WorkflowAction::Complete { .. } | WorkflowAction::Fail { .. } => &[],
307 }
308 }
309}
310fn validate_continuation(
311 state: &Value,
312 continuation: &str,
313 commands: &[WorkflowChildCommand],
314) -> Result<()> {
315 checkpoint(state)?;
316 validate_text(continuation, 128)?;
317 if commands.len() > WORKFLOW_MAX_COMMANDS {
318 return Err(invalid("workflow command batch exceeds supported limit"));
319 }
320 let mut keys = BTreeSet::new();
321 for command in commands {
322 command.validate()?;
323 if !keys.insert(&command.key) {
324 return Err(invalid("duplicate workflow command key"));
325 }
326 }
327 Ok(())
328}
329fn checkpoint(state: &Value) -> Result<()> {
330 validate_wire_value(state)?;
331 bounded(state, WORKFLOW_CHECKPOINT_MAX_BYTES, "workflow checkpoint")
332}
333fn version(v: u8) -> Result<()> {
334 if v != WORKFLOW_VERSION {
335 return Err(invalid("unsupported workflow version"));
336 }
337 Ok(())
338}
339pub fn validate_workflow_error(error: &ApplicationError) -> Result<()> {
340 validate_error(error)
341}
342fn validate_error(error: &ApplicationError) -> Result<()> {
343 validate_text(&error.kind, 128)?;
344 if error.message.len() > 4096 {
345 return Err(invalid("workflow error message exceeds 4096 bytes"));
346 }
347 Ok(())
348}
349fn invalid(message: &str) -> ContractError {
350 ContractError::InvalidInput(message.into())
351}
352fn encoding(error: serde_json::Error) -> ContractError {
353 ContractError::InvalidInput(format!("invalid workflow JSON: {error}"))
354}
355fn bounded(value: &impl Serialize, bytes: usize, label: &str) -> Result<()> {
356 crate::submission::check_encoded_size(value, bytes, label).map_err(Into::into)
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
360#[serde(rename_all = "snake_case")]
361pub enum WorkflowState {
362 Running,
363 Waiting,
364 Failing,
365 Cancelling,
366 Succeeded,
367 Failed,
368 Cancelled,
369}
370impl WorkflowState {
371 pub fn is_terminal(self) -> bool {
372 matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
373 }
374}
375#[derive(Debug, Clone, Serialize, Deserialize)]
376#[serde(deny_unknown_fields)]
377pub struct WorkflowSnapshot {
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub parent_workflow_id: Option<String>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub root_workflow_id: Option<String>,
383 pub workflow_id: String,
384 pub scope: Scope,
385 pub state: WorkflowState,
386 pub revision: u64,
387 #[serde(deserialize_with = "crate::observation::required_option")]
388 pub activation_id: Option<String>,
389 pub submitted_at: Timestamp,
390 #[serde(deserialize_with = "crate::observation::required_option")]
391 pub terminal_at: Option<Timestamp>,
392 #[serde(deserialize_with = "crate::observation::required_option")]
393 pub correlation_key: Option<String>,
394}
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
397pub enum WorkflowOutcome {
398 Succeeded {
399 #[serde(deserialize_with = "crate::observation::required_value")]
400 output: Value,
401 },
402 Failed {
403 error: ApplicationError,
404 },
405 Cancelled {},
406}
407#[derive(Debug, Clone, Serialize, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct WorkflowResult {
410 pub workflow: WorkflowSnapshot,
411 #[serde(deserialize_with = "crate::observation::required_option")]
412 pub outcome: Option<WorkflowOutcome>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416#[serde(deny_unknown_fields)]
417pub struct LocalResultCommand {
418 pub owner: LeaseOwner,
419 pub record: LocalStepRecord,
420}
421#[derive(Debug, Clone, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct LocalResultReceipt {
424 pub key: String,
425 pub already_accepted: bool,
426}
427#[derive(Debug, Clone)]
428pub struct WorkflowWork {
429 pub id: String,
430 pub token: String,
431 pub workflow_id: String,
432 pub source: WorkflowWorkSource,
433 pub outcome: Option<TaskOutcome>,
436 pub resolved_children: Vec<ResolvedWorkflowChild>,
438}
439#[derive(Debug, Clone)]
440pub struct ResolvedWorkflowChild {
441 pub kind: WorkflowChildKind,
442 pub key: String,
443 pub descriptor: ProgramDescriptor,
444}
445#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
446pub struct WorkflowProgress {
447 pub processed: u32,
448 pub activations_scheduled: u32,
449 pub children_scheduled: u32,
451}
452
453pub trait WorkflowStore: Send + Sync {
470 fn send_workflow_event<'a>(
475 &'a self,
476 command: &'a WorkflowEventCommand,
477 ) -> ContractFuture<'a, WorkflowEventReceipt>;
478 fn lookup_workflow_submission<'a>(
479 &'a self,
480 scope: &'a Scope,
481 key: &'a str,
482 ) -> ContractFuture<'a, Option<WorkflowSnapshot>>;
483 fn replay_workflow_submission<'a>(
485 &'a self,
486 command: &'a SubmitCommand,
487 ) -> ContractFuture<'a, Option<WorkflowSnapshot>>;
488 fn accept_resolved_workflow<'a>(
489 &'a self,
490 command: &'a SubmitCommand,
491 controller: &'a ProgramDescriptor,
492 ) -> ContractFuture<'a, WorkflowSnapshot>;
493 fn workflow_status<'a>(
494 &'a self,
495 scope: &'a Scope,
496 id: &'a str,
497 ) -> ContractFuture<'a, WorkflowSnapshot>;
498 fn workflow_result<'a>(
499 &'a self,
500 scope: &'a Scope,
501 id: &'a str,
502 ) -> ContractFuture<'a, WorkflowResult>;
503 fn activation_context<'a>(
504 &'a self,
505 owner: &'a LeaseOwner,
506 ) -> ContractFuture<'a, WorkflowActivationContext>;
507 fn record_local_result<'a>(
508 &'a self,
509 command: &'a LocalResultCommand,
510 ) -> ContractFuture<'a, LocalResultReceipt>;
511 fn claim_work(&self, limit: u32) -> ContractFuture<'_, Vec<WorkflowWork>>;
514 fn apply_work<'a>(
515 &'a self,
516 work: &'a WorkflowWork,
517 resolved: &'a [ResolvedWorkflowChild],
518 ) -> ContractFuture<'a, WorkflowProgress>;
519 fn retry_work<'a>(&'a self, work: &'a WorkflowWork, reason: &'a str) -> ContractFuture<'a, ()>;
520 fn reject_work<'a>(
522 &'a self,
523 work: &'a WorkflowWork,
524 error: &'a ApplicationError,
525 ) -> ContractFuture<'a, ()>;
526 fn cancel_workflow<'a>(
527 &'a self,
528 scope: &'a Scope,
529 id: &'a str,
530 ) -> ContractFuture<'a, WorkflowSnapshot>;
531}
532
533pub trait WorkflowService: Send + Sync {
536 fn send_workflow_event<'a>(
541 &'a self,
542 command: &'a WorkflowEventCommand,
543 ) -> ContractFuture<'a, WorkflowEventReceipt>;
544 fn submit_workflow<'a>(
545 &'a self,
546 command: &'a SubmitCommand,
547 ) -> ContractFuture<'a, WorkflowSnapshot>;
548 fn workflow_status<'a>(
549 &'a self,
550 scope: &'a Scope,
551 id: &'a str,
552 ) -> ContractFuture<'a, WorkflowSnapshot>;
553 fn workflow_result<'a>(
554 &'a self,
555 scope: &'a Scope,
556 id: &'a str,
557 ) -> ContractFuture<'a, WorkflowResult>;
558 fn activation_context<'a>(
559 &'a self,
560 owner: &'a LeaseOwner,
561 ) -> ContractFuture<'a, WorkflowActivationContext>;
562 fn record_local_result<'a>(
563 &'a self,
564 command: &'a LocalResultCommand,
565 ) -> ContractFuture<'a, LocalResultReceipt>;
566 fn cancel_workflow<'a>(
567 &'a self,
568 scope: &'a Scope,
569 id: &'a str,
570 ) -> ContractFuture<'a, WorkflowSnapshot>;
571}
572
573impl WorkflowSnapshot {
574 pub fn validate(&self) -> Result<()> {
575 self.scope.validate()?;
576 validate_workflow_lineage(
577 Some(&self.workflow_id),
578 self.parent_workflow_id.as_deref(),
579 self.root_workflow_id.as_deref(),
580 )?;
581 if let Some(id) = &self.activation_id {
582 validate_text(id, 128)?;
583 }
584 if self.state.is_terminal() != self.terminal_at.is_some()
585 || (self.state.is_terminal() && self.activation_id.is_some())
586 || (self.state == WorkflowState::Running && self.activation_id.is_none())
587 || self.terminal_at.is_some_and(|at| at < self.submitted_at)
588 || self.submitted_at > 253_402_300_799_999
589 || self.terminal_at.is_some_and(|at| at > 253_402_300_799_999)
590 {
591 return Err(invalid("inconsistent workflow status"));
592 }
593 if self
594 .correlation_key
595 .as_ref()
596 .is_some_and(|key| key.len() > 512 || key.chars().any(char::is_control))
597 {
598 return Err(invalid("invalid workflow correlation key"));
599 }
600 Ok(())
601 }
602}
603impl WorkflowResult {
604 pub fn validate(&self) -> Result<()> {
605 self.workflow.validate()?;
606 match (&self.outcome, self.workflow.state) {
607 (None, state) if !state.is_terminal() => Ok(()),
608 (Some(WorkflowOutcome::Succeeded { output }), WorkflowState::Succeeded) => {
609 validate_wire_value(output)?;
610 bounded(output, WORKFLOW_DECISION_MAX_BYTES, "workflow output")
611 }
612 (Some(WorkflowOutcome::Failed { error }), WorkflowState::Failed) => {
613 validate_error(error)
614 }
615 (Some(WorkflowOutcome::Cancelled {}), WorkflowState::Cancelled) => Ok(()),
616 _ => Err(invalid("inconsistent workflow result")),
617 }
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use serde_json::json;
625 fn decision() -> Value {
626 json!({"v":1,"activation_id":"task_1","revision":0,"kind":"suspend","continuation":"after","state":null,"commands":[],"until":[]})
627 }
628 #[test]
629 fn decisions_require_exact_versioned_shape_and_sealed_unique_membership() {
630 assert!(WorkflowDecision::decode(&decision()).is_ok());
631 for (key, value) in [
632 ("v", json!(2)),
633 ("commands", json!(null)),
634 ("until", json!(["a", "a"])),
635 ("extra", json!(true)),
636 ] {
637 let mut input = decision();
638 input[key] = value;
639 assert!(WorkflowDecision::decode(&input).is_err(), "{key}");
640 }
641 let mut input = decision();
642 input.as_object_mut().unwrap().remove("state");
643 assert!(WorkflowDecision::decode(&input).is_err());
644 }
645 #[test]
646 fn durable_local_replay_binds_numeric_representation_and_result() {
647 let record = LocalStepRecord {
648 key: "fetch".into(),
649 callable: "app:fetch".into(),
650 input: json!({"a":1,"b":2}),
651 output: Value::Null,
652 };
653 let mut replay = record.clone();
654 replay.input = json!({"b":2,"a":1});
655 assert!(record.matches(&replay).unwrap());
656 replay.input = json!({"a":1.0,"b":2});
657 assert!(!record.matches(&replay).unwrap());
658 replay = record.clone();
659 replay.output = json!(false);
660 assert!(!record.matches(&replay).unwrap());
661 }
662 #[test]
663 fn oversized_checkpoints_fail_before_persistence() {
664 let mut input = decision();
665 input["state"] = Value::String("x".repeat(WORKFLOW_CHECKPOINT_MAX_BYTES));
666 assert!(WorkflowDecision::decode(&input).is_err());
667 }
668 #[test]
669 fn activation_context_rejects_nested_child_values_beyond_the_application_bound() {
670 let mut output = Value::Null;
671 for _ in 0..64 {
672 output = json!([output]);
673 }
674 let child = WorkflowChildResult::Task(WorkflowTaskResult {
675 task_id: "child".into(),
676 state: TaskState::Succeeded,
677 outcome: TaskOutcome::Succeeded {
678 attempt_id: "attempt".into(),
679 quiescence: Quiescence::Confirmed,
680 execution_may_have_started: true,
681 output,
682 },
683 });
684 let mut context = WorkflowActivationContext {
685 parent_workflow_id: None,
686 root_workflow_id: None,
687 v: 1,
688 workflow_id: "workflow".into(),
689 activation_id: "activation".into(),
690 revision: 0,
691 continuation: "next".into(),
692 state: Value::Null,
693 inputs: BTreeMap::from([("child".into(), child)]),
694 local_steps: vec![],
695 wake: None,
696 };
697 context.validate().unwrap();
698 let WorkflowChildResult::Task(child) = context.inputs.get_mut("child").unwrap() else {
699 unreachable!()
700 };
701 let TaskOutcome::Succeeded { output, .. } = &mut child.outcome else {
702 unreachable!()
703 };
704 *output = json!([output.take()]);
705 assert!(context.validate().is_err());
706 }
707
708 #[test]
709 fn workflow_wire_distinguishes_explicit_null_from_missing_required_fields() {
710 let snapshot = json!({"workflow_id":"workflow","scope":{"tenant_id":"t","namespace":"n"},"state":"waiting","revision":1,"activation_id":null,"submitted_at":1,"terminal_at":null,"correlation_key":null});
711 serde_json::from_value::<WorkflowSnapshot>(snapshot.clone())
712 .unwrap()
713 .validate()
714 .unwrap();
715 for key in ["activation_id", "terminal_at", "correlation_key"] {
716 let mut missing = snapshot.clone();
717 missing.as_object_mut().unwrap().remove(key);
718 assert!(
719 serde_json::from_value::<WorkflowSnapshot>(missing).is_err(),
720 "{key}"
721 );
722 }
723 assert!(
724 serde_json::from_value::<WorkflowResult>(json!({"workflow":snapshot,"outcome":null}))
725 .is_ok()
726 );
727 assert!(serde_json::from_value::<WorkflowResult>(json!({"workflow":snapshot})).is_err());
728 assert!(
729 serde_json::from_value::<WorkflowOutcome>(json!({"kind":"succeeded","output":null}))
730 .is_ok()
731 );
732 assert!(serde_json::from_value::<WorkflowOutcome>(json!({"kind":"succeeded"})).is_err());
733 let local = json!({"key":"key","callable":"app:f","input":null,"output":null});
734 serde_json::from_value::<LocalStepRecord>(local.clone())
735 .unwrap()
736 .validate()
737 .unwrap();
738 for key in ["input", "output"] {
739 let mut missing = local.clone();
740 missing.as_object_mut().unwrap().remove(key);
741 assert!(serde_json::from_value::<LocalStepRecord>(missing).is_err());
742 }
743 }
744}