1use std::{collections::BTreeMap, fmt, num::NonZeroU16, sync::Arc};
2
3use runifold_core::{
4 Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, CheckpointStore, RunContext,
5 RunId, Usage,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::{StepId, WorkflowError, WorkflowOutcome, WorkflowRemediationCheckpoint, WorkflowWait};
11use crate::{WorkflowLease, WorkflowStore};
12
13const CHECKPOINT_KIND: &str = "runifold.workflow";
14const CHECKPOINT_SCHEMA_VERSION: u32 = 5;
15const MIN_CHECKPOINT_SCHEMA_VERSION: u32 = 3;
16
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19#[non_exhaustive]
20pub enum WorkflowResumePolicy {
21 #[default]
23 RejectAmbiguous,
24 RetryInterruptedStep,
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub struct WorkflowCheckpointHistoryLimit(NonZeroU16);
31
32impl WorkflowCheckpointHistoryLimit {
33 pub fn new(value: u16) -> Result<Self, CheckpointError> {
39 NonZeroU16::new(value)
40 .filter(|value| value.get() <= 256)
41 .map(Self)
42 .ok_or_else(|| {
43 CheckpointError::new(
44 CheckpointErrorKind::InvalidPayload,
45 "workflow checkpoint history limit must be in 1..=256",
46 )
47 })
48 }
49
50 pub const fn get(self) -> u16 {
52 self.0.get()
53 }
54}
55
56#[derive(Clone, Debug, PartialEq)]
58pub struct WorkflowCheckpointRevision {
59 pub checkpoint_id: CheckpointId,
61 pub revision: u64,
63 pub run_id: RunId,
65 pub updated_at_ms: u64,
67 pub state: WorkflowCheckpointState,
69}
70
71impl WorkflowCheckpointRevision {
72 #[doc(hidden)]
73 pub fn from_checkpoint(checkpoint: Checkpoint) -> Result<Self, CheckpointError> {
74 decode_revision(checkpoint)
75 }
76}
77
78#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(rename_all = "snake_case")]
81#[non_exhaustive]
82pub enum WorkflowForkPolicy {
83 #[default]
85 RejectAmbiguous,
86 RetryInterruptedStep,
88}
89
90#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
92pub struct WorkflowForkCommand {
93 pub fork_checkpoint_id: CheckpointId,
95 pub source_checkpoint_id: CheckpointId,
97 pub source_revision: u64,
99 pub policy: WorkflowForkPolicy,
101}
102
103impl WorkflowForkCommand {
104 pub fn new(
106 source_checkpoint_id: CheckpointId,
107 source_revision: u64,
108 policy: WorkflowForkPolicy,
109 ) -> Self {
110 Self::with_id(
111 CheckpointId::new(),
112 source_checkpoint_id,
113 source_revision,
114 policy,
115 )
116 }
117
118 pub const fn with_id(
120 fork_checkpoint_id: CheckpointId,
121 source_checkpoint_id: CheckpointId,
122 source_revision: u64,
123 policy: WorkflowForkPolicy,
124 ) -> Self {
125 Self {
126 fork_checkpoint_id,
127 source_checkpoint_id,
128 source_revision,
129 policy,
130 }
131 }
132
133 #[doc(hidden)]
134 pub fn prepare_checkpoint(&self, source: Checkpoint) -> Result<Checkpoint, CheckpointError> {
135 fork_checkpoint(source, self.fork_checkpoint_id, self.policy)
136 }
137}
138
139#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
141pub struct WorkflowLineage {
142 pub parent_checkpoint_id: CheckpointId,
144 pub parent_revision: u64,
146 pub policy: WorkflowForkPolicy,
148}
149
150#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152#[non_exhaustive]
153pub enum WorkflowForkOutcome {
154 Created {
156 checkpoint_id: CheckpointId,
158 },
159 Duplicate {
161 checkpoint_id: CheckpointId,
163 },
164}
165
166#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
168#[serde(tag = "state", rename_all = "snake_case")]
169#[non_exhaustive]
170pub enum WorkflowCheckpointPhase {
171 Ready,
173 StepInFlight {
175 step: StepId,
177 },
178 Remediating {
180 step: StepId,
182 attempt: u32,
184 original_input: Value,
186 checkpoint: WorkflowRemediationCheckpoint,
188 },
189 Waiting {
191 step: StepId,
193 wait: WorkflowWait,
195 },
196 ParallelInFlight {
198 step: StepId,
200 branches: BTreeMap<StepId, ParallelBranchCheckpoint>,
202 },
203 RaceInFlight {
205 step: StepId,
207 branches: BTreeMap<StepId, ParallelBranchCheckpoint>,
209 },
210 Completed {
212 outcome: WorkflowOutcome,
214 },
215}
216
217#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
219#[serde(tag = "state", rename_all = "snake_case")]
220#[non_exhaustive]
221pub enum ParallelBranchCheckpoint {
222 InFlight,
224 Completed {
226 output: Value,
228 },
229 Failed {
231 message: String,
233 },
234 Cancelled,
236}
237
238#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
240pub struct WorkflowCheckpointState {
241 pub workflow: String,
243 pub workflow_version: u32,
245 pub layout: Vec<StepId>,
247 pub next_index: usize,
249 pub value: Value,
251 pub outputs: BTreeMap<StepId, Value>,
253 pub usage: Usage,
255 pub phase: WorkflowCheckpointPhase,
257}
258
259impl WorkflowCheckpointState {
260 pub(crate) fn outcome(&self) -> Option<WorkflowOutcome> {
261 match &self.phase {
262 WorkflowCheckpointPhase::Completed { outcome } => Some(outcome.clone()),
263 _ => None,
264 }
265 }
266}
267
268#[derive(Clone)]
270pub struct WorkflowCheckpoint {
271 id: CheckpointId,
272 backend: WorkflowCheckpointBackend,
273}
274
275#[derive(Clone)]
276enum WorkflowCheckpointBackend {
277 Local(Arc<dyn CheckpointStore>),
278 Distributed {
279 store: Arc<dyn WorkflowStore>,
280 lease: WorkflowLease,
281 },
282}
283
284impl WorkflowCheckpoint {
285 pub fn new(store: Arc<dyn CheckpointStore>) -> Self {
287 Self {
288 id: CheckpointId::new(),
289 backend: WorkflowCheckpointBackend::Local(store),
290 }
291 }
292
293 pub fn existing(id: CheckpointId, store: Arc<dyn CheckpointStore>) -> Self {
295 Self {
296 id,
297 backend: WorkflowCheckpointBackend::Local(store),
298 }
299 }
300
301 pub fn distributed(store: Arc<dyn WorkflowStore>, lease: WorkflowLease) -> Self {
303 Self {
304 id: lease.checkpoint_id,
305 backend: WorkflowCheckpointBackend::Distributed { store, lease },
306 }
307 }
308
309 pub const fn id(&self) -> CheckpointId {
311 self.id
312 }
313
314 pub fn load(&self) -> Result<(Checkpoint, WorkflowCheckpointState), CheckpointError> {
320 let WorkflowCheckpointBackend::Local(store) = &self.backend else {
321 return Err(CheckpointError::new(
322 CheckpointErrorKind::Storage,
323 "distributed workflow checkpoints must be loaded asynchronously",
324 ));
325 };
326 let checkpoint = store.load(self.id)?;
327 decode(checkpoint)
328 }
329
330 pub async fn load_async(
336 &self,
337 ) -> Result<(Checkpoint, WorkflowCheckpointState), CheckpointError> {
338 let checkpoint = match &self.backend {
339 WorkflowCheckpointBackend::Local(store) => store.load(self.id)?,
340 WorkflowCheckpointBackend::Distributed { store, lease } => {
341 store.load_checkpoint(lease.clone()).await?
342 }
343 };
344 decode(checkpoint)
345 }
346
347 async fn compare_and_swap(
348 &self,
349 checkpoint: &Checkpoint,
350 expected_revision: Option<u64>,
351 ) -> Result<(), CheckpointError> {
352 match &self.backend {
353 WorkflowCheckpointBackend::Local(store) => {
354 store.compare_and_swap(checkpoint, expected_revision)
355 }
356 WorkflowCheckpointBackend::Distributed { store, lease } => {
357 store
358 .compare_and_swap_checkpoint(
359 lease.clone(),
360 checkpoint.clone(),
361 expected_revision,
362 )
363 .await
364 }
365 }
366 }
367}
368
369fn decode(
370 checkpoint: Checkpoint,
371) -> Result<(Checkpoint, WorkflowCheckpointState), CheckpointError> {
372 if checkpoint.kind != CHECKPOINT_KIND
373 || !(MIN_CHECKPOINT_SCHEMA_VERSION..=CHECKPOINT_SCHEMA_VERSION)
374 .contains(&checkpoint.schema_version)
375 {
376 return Err(CheckpointError::new(
377 CheckpointErrorKind::InvalidPayload,
378 "checkpoint kind or schema version is not supported",
379 ));
380 }
381 let state = serde_json::from_value(checkpoint.payload.clone()).map_err(|error| {
382 CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
383 })?;
384 Ok((checkpoint, state))
385}
386
387pub(crate) fn decode_revision(
388 checkpoint: Checkpoint,
389) -> Result<WorkflowCheckpointRevision, CheckpointError> {
390 let (checkpoint, state) = decode(checkpoint)?;
391 Ok(WorkflowCheckpointRevision {
392 checkpoint_id: checkpoint.id,
393 revision: checkpoint.revision,
394 run_id: checkpoint.run_id,
395 updated_at_ms: checkpoint.updated_at_ms,
396 state,
397 })
398}
399
400pub(crate) fn fork_checkpoint(
401 source: Checkpoint,
402 target: CheckpointId,
403 policy: WorkflowForkPolicy,
404) -> Result<Checkpoint, CheckpointError> {
405 let (_, mut state) = decode(source)?;
406 match state.phase.clone() {
407 WorkflowCheckpointPhase::StepInFlight { .. }
408 if policy == WorkflowForkPolicy::RetryInterruptedStep =>
409 {
410 state.phase = WorkflowCheckpointPhase::Ready;
411 }
412 WorkflowCheckpointPhase::Remediating {
413 step,
414 attempt,
415 original_input,
416 checkpoint: WorkflowRemediationCheckpoint::GenerationInFlight { input },
417 } if policy == WorkflowForkPolicy::RetryInterruptedStep => {
418 state.phase = WorkflowCheckpointPhase::Remediating {
419 step,
420 attempt,
421 original_input,
422 checkpoint: WorkflowRemediationCheckpoint::GenerationReady { input },
423 };
424 }
425 WorkflowCheckpointPhase::Remediating {
426 step,
427 attempt,
428 original_input,
429 checkpoint: WorkflowRemediationCheckpoint::ReviewInFlight { candidate },
430 } if policy == WorkflowForkPolicy::RetryInterruptedStep => {
431 state.phase = WorkflowCheckpointPhase::Remediating {
432 step,
433 attempt,
434 original_input,
435 checkpoint: WorkflowRemediationCheckpoint::ReviewReady { candidate },
436 };
437 }
438 WorkflowCheckpointPhase::StepInFlight { .. }
439 | WorkflowCheckpointPhase::Remediating {
440 checkpoint:
441 WorkflowRemediationCheckpoint::GenerationInFlight { .. }
442 | WorkflowRemediationCheckpoint::ReviewInFlight { .. },
443 ..
444 }
445 | WorkflowCheckpointPhase::ParallelInFlight { .. }
446 | WorkflowCheckpointPhase::RaceInFlight { .. } => {
447 return Err(CheckpointError::new(
448 CheckpointErrorKind::Conflict,
449 "workflow checkpoint is ambiguous and cannot be forked safely",
450 ));
451 }
452 _ => {}
453 }
454 Ok(Checkpoint::initial(
455 target,
456 RunId::new(),
457 CHECKPOINT_KIND,
458 CHECKPOINT_SCHEMA_VERSION,
459 serde_json::to_value(state).map_err(|error| {
460 CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
461 })?,
462 ))
463}
464
465impl fmt::Debug for WorkflowCheckpoint {
466 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
467 formatter
468 .debug_struct("WorkflowCheckpoint")
469 .field("id", &self.id)
470 .finish_non_exhaustive()
471 }
472}
473
474pub(crate) struct WorkflowCheckpointCursor {
475 handle: WorkflowCheckpoint,
476 envelope: Checkpoint,
477}
478
479impl WorkflowCheckpointCursor {
480 pub(crate) async fn create(
481 handle: &WorkflowCheckpoint,
482 run: &RunContext,
483 state: &WorkflowCheckpointState,
484 ) -> Result<Self, WorkflowError> {
485 let envelope = Checkpoint::initial(
486 handle.id,
487 run.run_id(),
488 CHECKPOINT_KIND,
489 CHECKPOINT_SCHEMA_VERSION,
490 serialize(state)?,
491 );
492 handle.compare_and_swap(&envelope, None).await?;
493 Ok(Self {
494 handle: handle.clone(),
495 envelope,
496 })
497 }
498
499 pub(crate) fn loaded(handle: &WorkflowCheckpoint, envelope: Checkpoint) -> Self {
500 Self {
501 handle: handle.clone(),
502 envelope,
503 }
504 }
505
506 pub(crate) async fn save(
507 &mut self,
508 state: &WorkflowCheckpointState,
509 ) -> Result<(), WorkflowError> {
510 let next = self.envelope.next(serialize(state)?)?;
511 self.handle
512 .compare_and_swap(&next, Some(self.envelope.revision))
513 .await?;
514 self.envelope = next;
515 Ok(())
516 }
517}
518
519fn serialize(state: &WorkflowCheckpointState) -> Result<Value, WorkflowError> {
520 serde_json::to_value(state).map_err(|error| {
521 CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string()).into()
522 })
523}
524
525#[cfg(test)]
526mod tests {
527 use runifold_core::{CheckpointId, RunId, Usage};
528 use serde_json::json;
529
530 use super::*;
531
532 fn in_flight_checkpoint() -> Checkpoint {
533 let state = WorkflowCheckpointState {
534 workflow: "fork-test".into(),
535 workflow_version: 1,
536 layout: vec![StepId::parse("charge").unwrap()],
537 next_index: 0,
538 value: json!({"amount": 42}),
539 outputs: BTreeMap::new(),
540 usage: Usage {
541 tokens: 7,
542 ..Usage::default()
543 },
544 phase: WorkflowCheckpointPhase::StepInFlight {
545 step: StepId::parse("charge").unwrap(),
546 },
547 };
548 Checkpoint::initial(
549 CheckpointId::new(),
550 RunId::new(),
551 CHECKPOINT_KIND,
552 CHECKPOINT_SCHEMA_VERSION,
553 serde_json::to_value(state).unwrap(),
554 )
555 }
556
557 #[test]
558 fn fork_rejects_ambiguous_replay_unless_explicitly_authorized() {
559 let source = in_flight_checkpoint();
560 let error = fork_checkpoint(
561 source.clone(),
562 CheckpointId::new(),
563 WorkflowForkPolicy::RejectAmbiguous,
564 )
565 .unwrap_err();
566 assert_eq!(error.kind, CheckpointErrorKind::Conflict);
567
568 let forked = fork_checkpoint(
569 source,
570 CheckpointId::new(),
571 WorkflowForkPolicy::RetryInterruptedStep,
572 )
573 .unwrap();
574 let revision = decode_revision(forked).unwrap();
575 assert!(matches!(
576 revision.state.phase,
577 WorkflowCheckpointPhase::Ready
578 ));
579 assert_eq!(revision.state.usage.tokens, 7);
580 assert_eq!(revision.state.next_index, 0);
581 }
582
583 #[test]
584 fn schema_v4_ready_checkpoint_remains_readable() {
585 let checkpoint = Checkpoint::initial(
586 CheckpointId::new(),
587 RunId::new(),
588 CHECKPOINT_KIND,
589 4,
590 json!({
591 "workflow": "v4-compatible",
592 "workflow_version": 7,
593 "layout": ["draft"],
594 "next_index": 0,
595 "value": {"request": "review this"},
596 "outputs": {},
597 "usage": {
598 "tokens": 11,
599 "cost_microusd": 12,
600 "duration_micros": 13,
601 "turns": 14,
602 "tool_calls": 15,
603 "delegations": 16
604 },
605 "phase": {"state": "ready"}
606 }),
607 );
608
609 let revision = decode_revision(checkpoint).unwrap();
610
611 assert_eq!(revision.state.workflow, "v4-compatible");
612 assert_eq!(revision.state.workflow_version, 7);
613 assert_eq!(revision.state.layout, [StepId::parse("draft").unwrap()]);
614 assert_eq!(revision.state.usage.tokens, 11);
615 assert!(matches!(
616 revision.state.phase,
617 WorkflowCheckpointPhase::Ready
618 ));
619 }
620
621 #[test]
622 fn future_checkpoint_schema_is_rejected() {
623 let mut checkpoint = in_flight_checkpoint();
624 checkpoint.schema_version = CHECKPOINT_SCHEMA_VERSION + 1;
625
626 let error = decode_revision(checkpoint).unwrap_err();
627
628 assert_eq!(error.kind, CheckpointErrorKind::InvalidPayload);
629 }
630}