1use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use serde_json::{json, Map, Value};
12
13use crate::{
14 ingress_plans, schedule_decision, ActionIntent, ActionState, CapabilityKind,
15 CapabilityManifest, CatchUpPolicy, EvaluationOutcome, Event, HostError, MemoryState,
16 NodeRegistry, ScheduleCadence, SchedulePolicy, Spec, Terminal, WorkDisposition, WorkItem,
17 WorkflowDriver, WorkflowHost, WorkflowTransitionCommand,
18};
19
20const IDLE_WAIT_SECS: i64 = 86_400;
24
25pub struct SpecDriver {
27 spec_id: String,
28 host: WorkflowHost,
29 schedule: Option<SchedulePolicy>,
30 actions: BTreeMap<String, CapabilityManifest>,
31}
32
33impl SpecDriver {
34 pub fn new(spec: &Spec, registry: &NodeRegistry) -> Result<Self, HostError> {
36 let host = WorkflowHost::from_spec(spec, registry)?;
37 let schedule = ingress_plans(spec, registry)
38 .into_iter()
39 .find(|plan| plan.branch_id == host.branch_id())
40 .map(|plan| {
41 let required_u64 = |name: &str| {
42 plan.ingress_config[name]
43 .as_u64()
44 .ok_or_else(|| HostError::Schedule {
45 spec_id: spec.spec_id.clone(),
46 reason: format!("{} requires integer '{name}'", plan.ingress_type),
47 })
48 };
49 let cadence = match plan.ingress_type.as_str() {
50 "ingress.cron" => ScheduleCadence::Cron {
51 expression: plan.ingress_config["expression"]
52 .as_str()
53 .ok_or_else(|| HostError::Schedule {
54 spec_id: spec.spec_id.clone(),
55 reason: "ingress.cron requires a string 'expression'".into(),
56 })?
57 .to_owned(),
58 },
59 "ingress.fixed_rate" => ScheduleCadence::FixedRate {
60 milliseconds: required_u64("milliseconds")?,
61 },
62 "ingress.fixed_delay" => ScheduleCadence::FixedDelay {
63 milliseconds: required_u64("milliseconds")?,
64 },
65 _ => return Ok(None),
66 };
67 let catch_up = match plan.ingress_config["catch_up"].as_str().unwrap_or("once") {
68 "skip" => CatchUpPolicy::Skip,
69 "once" => CatchUpPolicy::CatchUpOnce,
70 "all" => CatchUpPolicy::CatchUpAll {
71 limit: plan.ingress_config["catch_up_limit"]
72 .as_u64()
73 .and_then(|value| u32::try_from(value).ok())
74 .ok_or_else(|| HostError::Schedule {
75 spec_id: spec.spec_id.clone(),
76 reason: "catch_up=all requires positive integer catch_up_limit"
77 .into(),
78 })?,
79 },
80 value => {
81 return Err(HostError::Schedule {
82 spec_id: spec.spec_id.clone(),
83 reason: format!("unknown catch_up policy '{value}'"),
84 })
85 }
86 };
87 let policy = SchedulePolicy {
88 cadence,
89 timezone: plan.ingress_config["timezone"]
90 .as_str()
91 .unwrap_or("UTC")
92 .to_owned(),
93 catch_up,
94 };
95 policy.validate().map_err(|error| HostError::Schedule {
96 spec_id: spec.spec_id.clone(),
97 reason: error.to_string(),
98 })?;
99 Ok::<_, HostError>(Some(policy))
100 })
101 .transpose()?
102 .flatten();
103 let actions = registry
104 .capability_manifests()
105 .filter(|manifest| manifest.kind == CapabilityKind::Action)
106 .map(|manifest| (manifest.id.clone(), manifest.clone()))
107 .collect();
108 Ok(Self {
109 spec_id: spec.spec_id.clone(),
110 host,
111 schedule,
112 actions,
113 })
114 }
115
116 fn action_intents(
117 &self,
118 item: &WorkItem,
119 requests: Vec<crate::PreparedAction>,
120 ) -> Result<Vec<ActionIntent>, String> {
121 requests
122 .into_iter()
123 .enumerate()
124 .map(|(index, request)| {
125 request.validate()?;
126 let manifest = self.actions.get(&request.capability_id).ok_or_else(|| {
127 format!(
128 "action step emitted unknown capability '{}'",
129 request.capability_id
130 )
131 })?;
132 let capability = item
133 .capability_pins
134 .iter()
135 .find(|pin| {
136 pin.id == manifest.id
137 && pin.contract_version == manifest.contract_version
138 && pin.content_digest == manifest.content_digest
139 })
140 .cloned()
141 .ok_or_else(|| {
142 format!(
143 "workflow revision does not pin action capability '{}'",
144 manifest.id
145 )
146 })?;
147 Ok(ActionIntent {
148 id: format!("{}:{}:action:{index}", item.id, item.state_version),
149 tenant_id: item.tenant_id.clone(),
150 instance_id: item
151 .id
152 .parse()
153 .map_err(|error: af_context::EmptyId| error.to_string())?,
154 run_id: item.run_id.clone(),
155 capability,
156 idempotency_key: format!(
157 "{}:{}:{}",
158 item.id, item.state_version, request.idempotency_key
159 ),
160 state: ActionState::Prepared,
161 input: request.input,
162 effect: manifest.effect,
163 retry_class: manifest.idempotency_mode,
164 control_epochs: item.control_epochs,
165 resource_scope_id: request.resource_scope_id,
166 lease_epoch: item.lease_version,
167 action_epoch: item.state_version,
168 deadline: request.deadline,
171 reservation: request.reservation,
172 created_at: item.claimed_at,
173 })
174 })
175 .collect()
176 }
177
178 fn terminal_action(
179 &self,
180 item: &WorkItem,
181 next_state: &mut Map<String, Value>,
182 ) -> Result<Option<WorkflowTransitionCommand>, String> {
183 let Some(wakeup) = item
184 .wakeups
185 .iter()
186 .find(|wakeup| wakeup.payload["kind"] == "terminal_action")
187 else {
188 return Ok(None);
189 };
190 let observation: crate::ActionObservation =
191 serde_json::from_value(wakeup.payload["observation"].clone())
192 .map_err(|error| format!("terminal action fact: {error}"))?;
193 let Some(internal) = next_state
194 .get_mut("__workflow")
195 .and_then(Value::as_object_mut)
196 else {
197 return Err("terminal action has no durable pending-action state".into());
198 };
199 let pending = internal
200 .get_mut("pending_actions")
201 .and_then(Value::as_array_mut)
202 .ok_or_else(|| "terminal action has no pending action list".to_string())?;
203 let before = pending.len();
204 pending.retain(|id| id.as_str() != Some(&observation.action_intent_id));
205 if pending.len() == before {
206 return Err("terminal action does not belong to this workflow instance".into());
207 }
208 let disposition = if pending.is_empty() {
209 internal
210 .remove("resume_at")
211 .map(serde_json::from_value)
212 .transpose()
213 .map_err(|error| format!("stored workflow resume_at: {error}"))?
214 .map(|at| WorkDisposition::Reschedule { at })
215 .unwrap_or_else(|| {
216 if internal
217 .get("static_event_consumed")
218 .and_then(Value::as_bool)
219 .unwrap_or(false)
220 {
221 WorkDisposition::Complete
222 } else {
223 Self::idle_disposition()
224 }
225 })
226 } else {
227 WorkDisposition::Continue {
228 delay_secs: IDLE_WAIT_SECS,
229 }
230 };
231 let succeeded = matches!(
232 observation.state.as_str(),
233 "completed" | "max_steps_reached" | "succeeded"
234 );
235 Ok(Some(command(
236 item,
237 Value::Object(next_state.clone()),
238 Vec::new(),
239 EvaluationOutcome {
240 triggered: true,
241 matched: true,
242 succeeded,
243 action_terminal: true,
244 },
245 disposition,
246 "workflow.action_observed",
247 )))
248 }
249
250 fn catch_up_count(state: &Map<String, Value>) -> u32 {
251 state
252 .get("__workflow")
253 .and_then(Value::as_object)
254 .and_then(|internal| internal.get("catch_up_count"))
255 .and_then(Value::as_u64)
256 .and_then(|value| u32::try_from(value).ok())
257 .unwrap_or(0)
258 }
259
260 fn set_internal(state: &mut Map<String, Value>, key: &str, value: Value) -> Result<(), String> {
261 let internal = state
262 .entry("__workflow")
263 .or_insert_with(|| json!({}))
264 .as_object_mut()
265 .ok_or_else(|| "workflow internal state must be an object".to_string())?;
266 internal.insert(key.into(), value);
267 Ok(())
268 }
269
270 fn schedule_decision(
271 &self,
272 item: &WorkItem,
273 state: &Map<String, Value>,
274 ) -> Result<Option<crate::ScheduleDecision>, String> {
275 if self.schedule.is_some() && !item.wakeups.is_empty() {
276 return Ok(Some(crate::ScheduleDecision {
277 tick_at: None,
278 next_at: item.scheduled_at,
279 catch_up_count: Self::catch_up_count(state),
280 }));
281 }
282 self.schedule
283 .as_ref()
284 .map(|schedule| {
285 schedule_decision(
286 schedule,
287 item.scheduled_at,
288 item.claimed_at,
289 Self::catch_up_count(state),
290 )
291 })
292 .transpose()
293 }
294
295 fn idle_disposition() -> WorkDisposition {
296 WorkDisposition::Continue {
297 delay_secs: IDLE_WAIT_SECS,
298 }
299 }
300}
301
302#[async_trait]
303impl<Context: Send + Sync> WorkflowDriver<Context> for SpecDriver {
304 fn name(&self) -> &'static str {
305 "spec"
306 }
307
308 fn spec_ids(&self) -> Vec<&str> {
309 vec![&self.spec_id]
310 }
311
312 fn validate_specs(&self) -> Result<(), String> {
313 Ok(())
314 }
315
316 async fn evaluate(
317 &self,
318 _: &Context,
319 item: &WorkItem,
320 ) -> Result<WorkflowTransitionCommand, String> {
321 let mut next_state = match &item.config {
322 Value::Object(map) => map.clone(),
323 Value::Null => Map::new(),
324 _ => return Err("spec instance config must be a JSON object".into()),
325 };
326 if item.cancel_requested {
327 return Ok(command(
328 item,
329 Value::Object(next_state),
330 Vec::new(),
331 EvaluationOutcome::default(),
332 WorkDisposition::Complete,
333 "workflow.cancelled",
334 ));
335 }
336 if let Some(command) = self.terminal_action(item, &mut next_state)? {
337 return Ok(command);
338 }
339 if next_state
340 .get("__workflow")
341 .and_then(Value::as_object)
342 .and_then(|internal| internal.get("pending_actions"))
343 .and_then(Value::as_array)
344 .is_some_and(|pending| !pending.is_empty())
345 {
346 return Ok(command(
347 item,
348 Value::Object(next_state),
349 Vec::new(),
350 EvaluationOutcome::default(),
351 Self::idle_disposition(),
352 "workflow.action_waiting",
353 ));
354 }
355 let schedule = self.schedule_decision(item, &next_state)?;
356 if let Some(decision) = schedule {
357 Self::set_internal(
358 &mut next_state,
359 "catch_up_count",
360 json!(decision.catch_up_count),
361 )?;
362 if decision.tick_at.is_none() && item.wakeups.is_empty() {
363 return Ok(command(
364 item,
365 Value::Object(next_state),
366 Vec::new(),
367 EvaluationOutcome::default(),
368 WorkDisposition::Reschedule {
369 at: decision.next_at,
370 },
371 "workflow.schedule_skipped",
372 ));
373 }
374 }
375 let state = Arc::new(MemoryState::from_snapshot(
376 next_state
377 .get("state")
378 .and_then(Value::as_object)
379 .cloned()
380 .unwrap_or_default(),
381 ));
382 let wakeup_payload = item.wakeups.first().map(|wakeup| wakeup.payload.clone());
383 let static_event = next_state.get("event").cloned().filter(|_| {
384 !next_state
385 .get("__workflow")
386 .and_then(Value::as_object)
387 .and_then(|internal| internal.get("static_event_consumed"))
388 .and_then(Value::as_bool)
389 .unwrap_or(false)
390 });
391 let consumed_static_event = wakeup_payload.is_none() && static_event.is_some();
392 let payload = wakeup_payload
393 .or(static_event)
394 .or_else(|| {
395 schedule
396 .and_then(|decision| decision.tick_at)
397 .map(|tick_at| json!({ "tick_at": tick_at }))
398 })
399 .ok_or_else(|| "event workflow requires a trigger delivery".to_string())?;
400 if consumed_static_event {
401 Self::set_internal(&mut next_state, "static_event_consumed", json!(true))?;
402 }
403 let context = self.host.context(state.clone());
404 let outcome = self
405 .host
406 .run_event(&context, Event::from_json(payload))
407 .await
408 .ok_or_else(|| "spec has no root branch".to_string())?;
409 let (terminal, exit_reason) = match &outcome.terminal {
410 Terminal::Completed => ("completed", Value::Null),
411 Terminal::Dropped { node_id, reason } => {
412 ("dropped", json!({ "node_id": node_id, "reason": reason }))
413 }
414 };
415 next_state.insert("state".into(), Value::Object(state.snapshot()));
416 next_state.insert(
417 "last_run".into(),
418 json!({
419 "terminal": terminal,
420 "exit": exit_reason,
421 "steps_run": outcome.steps_run,
422 "survivors": outcome.survivors.len(),
423 "at": item.claimed_at,
424 }),
425 );
426 let action_intents = self.action_intents(item, outcome.actions)?;
427 let disposition = match schedule {
428 Some(decision) if action_intents.is_empty() => WorkDisposition::Reschedule {
429 at: decision.next_at,
430 },
431 Some(decision) => {
432 Self::set_internal(&mut next_state, "resume_at", json!(decision.next_at))?;
433 Self::idle_disposition()
434 }
435 None if action_intents.is_empty() && consumed_static_event => WorkDisposition::Complete,
436 None => Self::idle_disposition(),
437 };
438 if !action_intents.is_empty() {
439 let internal = next_state
440 .entry("__workflow")
441 .or_insert_with(|| json!({}))
442 .as_object_mut()
443 .ok_or_else(|| "workflow internal state must be an object".to_string())?;
444 let pending = internal
445 .entry("pending_actions")
446 .or_insert_with(|| json!([]))
447 .as_array_mut()
448 .ok_or_else(|| "workflow pending actions must be an array".to_string())?;
449 pending.extend(action_intents.iter().map(|intent| json!(intent.id)));
450 }
451 let evaluation = EvaluationOutcome {
452 triggered: true,
453 matched: outcome.matched,
454 succeeded: outcome.succeeded && action_intents.is_empty(),
455 action_terminal: false,
456 };
457 Ok(command(
458 item,
459 Value::Object(next_state),
460 action_intents,
461 evaluation,
462 disposition,
463 "workflow.spec_evaluated",
464 ))
465 }
466}
467
468fn command(
469 item: &WorkItem,
470 next_state: Value,
471 action_intents: Vec<ActionIntent>,
472 outcome: EvaluationOutcome,
473 disposition: WorkDisposition,
474 event_type: &str,
475) -> WorkflowTransitionCommand {
476 WorkflowTransitionCommand {
477 delivery_key: format!("spec:{}:{}", item.id, item.state_version),
478 delivery_digest: format!(
479 "{}:{}:{}",
480 item.workflow_revision_digest, item.execution_profile_digest, item.state_version
481 ),
482 event_type: event_type.into(),
483 event_digest: format!("{event_type}:{}:{}", item.id, item.state_version),
484 event_payload: json!({ "spec_id": item.spec_id, "wakeups": item.wakeups.len() }),
485 next_state,
486 action_intents,
487 outcome,
488 disposition,
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::{ControlEpochs, DriverRegistry, PreparedAction, StepNode, StepResult, Wakeup};
496
497 struct PassNode;
498
499 #[async_trait::async_trait]
500 impl StepNode for PassNode {
501 async fn process(&self, event: &Event, _: &crate::WorkflowContext) -> StepResult {
502 StepResult::Pass(event.clone())
503 }
504 }
505
506 struct ActionNode;
507
508 #[async_trait::async_trait]
509 impl StepNode for ActionNode {
510 async fn process(&self, event: &Event, _: &crate::WorkflowContext) -> StepResult {
511 StepResult::Action {
512 event: event.clone(),
513 action: Box::new(PreparedAction::new(
514 "execute.demo",
515 "request-1",
516 json!({"value": 1}),
517 )),
518 }
519 }
520 }
521
522 fn item(spec_id: &str, config: Value) -> WorkItem {
523 WorkItem {
524 id: "instance".into(),
525 run_id: uuid::Uuid::new_v4().to_string().parse().unwrap(),
526 tenant_id: "tenant".parse().unwrap(),
527 subject_id: "subject".parse().unwrap(),
528 spec_id: spec_id.into(),
529 definition_id: spec_id.into(),
530 workflow_revision: 1,
531 workflow_revision_digest: "digest".into(),
532 execution_profile_id: "profile".into(),
533 execution_profile_revision: 1,
534 execution_profile_digest: "profile-digest".into(),
535 kernel_abi_version: "1".into(),
536 capability_pins: Vec::new(),
537 lifecycle: crate::LifecyclePolicy::run_once(),
538 scheduled_at: chrono::Utc::now(),
539 claimed_at: chrono::Utc::now(),
540 config,
541 state_version: 0,
542 control_epochs: ControlEpochs::default(),
543 cancel_requested: false,
544 lease_version: 1,
545 wakeups: Vec::new(),
546 }
547 }
548
549 fn spec(ingress: &str, ingress_config: Value) -> Spec {
550 Spec::from_json(
551 &json!({
552 "spec_id": "counter", "version": "1",
553 "branches": [{
554 "branch_id": "__root__",
555 "nodes": [
556 {"id": "in", "type": ingress, "config": ingress_config},
557 {"id": "count", "type": "transform.state_append",
558 "config": {"key": "seen", "path": "value", "max_len": 3}}
559 ],
560 "edges": [{"source": "in", "target": "count"}]
561 }]
562 })
563 .to_string(),
564 )
565 .unwrap()
566 }
567
568 #[tokio::test]
569 async fn cron_spec_reschedules_and_persists_branch_state() {
570 let registry = NodeRegistry::with_builtins();
571 let driver = SpecDriver::new(
572 &spec("ingress.cron", json!({"expression": "0 0 * * * *"})),
573 ®istry,
574 )
575 .unwrap();
576 let first = WorkflowDriver::<()>::evaluate(
577 &driver,
578 &(),
579 &item("counter", json!({"event": {"value": 1}})),
580 )
581 .await
582 .unwrap();
583 let WorkDisposition::Reschedule { at } = first.disposition else {
584 panic!("cron spec must reschedule");
585 };
586 assert_eq!(first.next_state["last_run"]["terminal"], "completed");
587 let mut next = item("counter", first.next_state);
588 next.scheduled_at = at;
589 next.claimed_at = at;
590 let second = WorkflowDriver::<()>::evaluate(&driver, &(), &next)
591 .await
592 .unwrap();
593 assert_eq!(
594 second.next_state["state"]["__root__.seen"],
595 json!([1]),
596 "the consumed bootstrap event must not be replayed on a cron tick"
597 );
598 assert_eq!(second.next_state["last_run"]["at"], json!(at));
599 }
600
601 #[tokio::test]
602 async fn event_spec_consumes_a_wakeup_then_waits() {
603 let registry = NodeRegistry::with_builtins();
604 let driver = SpecDriver::new(&spec("ingress.event", json!({})), ®istry).unwrap();
605 assert_eq!(
606 WorkflowDriver::<()>::evaluate(&driver, &(), &item("counter", json!({})))
607 .await
608 .unwrap_err(),
609 "event workflow requires a trigger delivery"
610 );
611 let mut work = item("counter", json!({}));
612 work.wakeups.push(Wakeup {
613 id: "delivery".into(),
614 kind: "delivery".into(),
615 payload: json!({"value": 7}),
616 });
617 let command = WorkflowDriver::<()>::evaluate(&driver, &(), &work)
618 .await
619 .unwrap();
620 assert_eq!(command.next_state["state"]["__root__.seen"], json!([7]));
621 assert!(matches!(
622 command.disposition,
623 WorkDisposition::Continue { .. }
624 ));
625 let mut registry = DriverRegistry::<()>::new();
626 registry.register(Arc::new(driver)).unwrap();
627 assert_eq!(registry.spec_ids(), ["counter"]);
628 }
629
630 #[tokio::test]
631 async fn effectful_steps_are_lifted_into_pinned_intents() {
632 let mut registry = NodeRegistry::with_builtins();
633 registry.register_step("guard.auth", |_| Ok(Box::new(PassNode)));
634 registry.register_side_effect_guard("guard.auth");
635 registry.register_step("execute.demo", |_| Ok(Box::new(ActionNode)));
636 let manifest = CapabilityManifest::action(
637 "execute.demo",
638 "1",
639 "demo-digest",
640 crate::Effect::ExternalWrite,
641 crate::IdempotencyMode::Native,
642 true,
643 );
644 registry.register_capability(manifest.clone()).unwrap();
645 let effect = Spec::from_json(
646 &json!({
647 "spec_id": "effect", "version": "1",
648 "branches": [{
649 "branch_id": "__root__",
650 "nodes": [
651 {"id": "in", "type": "ingress.event", "config": {}},
652 {"id": "auth", "type": "guard.auth", "config": {}},
653 {"id": "do", "type": "execute.demo", "config": {}}
654 ],
655 "edges": [
656 {"source": "in", "target": "auth"},
657 {"source": "auth", "target": "do"}
658 ]
659 }]
660 })
661 .to_string(),
662 )
663 .unwrap();
664 let driver = SpecDriver::new(&effect, ®istry).unwrap();
665 let mut work = item("effect", json!({"event": {"value": 1}}));
666 work.capability_pins.push(crate::CapabilityPin {
667 id: manifest.id,
668 contract_version: manifest.contract_version,
669 content_digest: manifest.content_digest,
670 });
671 let command = WorkflowDriver::<()>::evaluate(&driver, &(), &work)
672 .await
673 .unwrap();
674 assert_eq!(command.action_intents.len(), 1);
675 assert_eq!(command.action_intents[0].state, ActionState::Prepared);
676 assert_eq!(command.action_intents[0].capability.id, "execute.demo");
677 assert_eq!(command.action_intents[0].deadline, None);
678
679 let mut waiting = item("effect", command.next_state.clone());
680 waiting.capability_pins = work.capability_pins.clone();
681 let waiting_command = WorkflowDriver::<()>::evaluate(&driver, &(), &waiting)
682 .await
683 .unwrap();
684 assert!(waiting_command.action_intents.is_empty());
685 assert_eq!(waiting_command.event_type, "workflow.action_waiting");
686
687 waiting.wakeups.push(Wakeup {
688 id: "terminal".into(),
689 kind: "timer".into(),
690 payload: json!({
691 "kind": "terminal_action",
692 "observation": {
693 "id": "observation",
694 "action_intent_id": command.action_intents[0].id,
695 "provider_version": "1",
696 "observed_at": chrono::Utc::now(),
697 "state": "rejected",
698 "resource_ref": null,
699 "raw_receipt_digest": "receipt",
700 "terminal": true,
701 "retry_authorized": false
702 }
703 }),
704 });
705 let terminal = WorkflowDriver::<()>::evaluate(&driver, &(), &waiting)
706 .await
707 .unwrap();
708 assert!(terminal.action_intents.is_empty());
709 assert!(matches!(terminal.disposition, WorkDisposition::Complete));
710 assert!(matches!(
711 SpecDriver::new(&spec("ingress.cron", json!({})), ®istry),
712 Err(HostError::Schedule { .. })
713 ));
714 }
715
716 #[tokio::test]
717 async fn delivery_does_not_advance_the_cron_cursor() {
718 let registry = NodeRegistry::with_builtins();
719 let driver = SpecDriver::new(
720 &spec("ingress.cron", json!({"expression": "0 0 * * * *"})),
721 ®istry,
722 )
723 .unwrap();
724 let mut work = item("counter", json!({}));
725 work.scheduled_at = work.claimed_at + chrono::Duration::hours(1);
726 work.wakeups.push(Wakeup {
727 id: "delivery".into(),
728 kind: "delivery".into(),
729 payload: json!({"value": 7}),
730 });
731 let command = WorkflowDriver::<()>::evaluate(&driver, &(), &work)
732 .await
733 .unwrap();
734 assert_eq!(command.next_state["state"]["__root__.seen"], json!([7]));
735 assert!(matches!(
736 command.disposition,
737 WorkDisposition::Reschedule { at } if at == work.scheduled_at
738 ));
739 }
740}