1use crate::agent::budget::BudgetBreach;
2use crate::agent::contract::Contract;
3use crate::agent::router::{CapabilitySelector, InboxRoute, Router};
4use crate::context::ContextAssembler;
5use crate::error::LaserError;
6#[cfg(feature = "kv")]
7use crate::kv::{KV_LEASE_OP_VERSION, KvLeaseRenew};
8use crate::laser::Laser;
9use crate::provenance::{AgentTopic, Provenance};
10use crate::types::{AgentId, ConversationId};
11use laser_wire::framing::{decode_named, encode_named};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::time::Duration;
15use tokio::time::Instant;
16
17#[derive(Serialize, Deserialize)]
22enum WorkflowJournalEntry {
23 StepCompleted { label: String, output: Vec<u8> },
24}
25
26#[cfg(feature = "kv")]
29pub const WORKFLOW_FENCE_NAMESPACE: &str = "agdx.workflow.fence";
30
31#[cfg(feature = "kv")]
33const WORKFLOW_LEASE_TTL: Duration = Duration::from_secs(60);
34
35const STEP_BUDGET_FLOOR: Duration = Duration::from_millis(100);
40
41const MAX_REASSIGNMENTS: u32 = 2;
43
44#[derive(Clone, Copy, Debug, Default)]
48pub struct WorkflowBudget {
49 tokens: Option<u64>,
50 wall_clock: Option<Duration>,
51 invocations: Option<u32>,
52}
53
54impl WorkflowBudget {
55 pub fn unlimited() -> Self {
57 Self::default()
58 }
59
60 pub fn tokens(tokens: u64) -> Self {
66 Self {
67 tokens: Some(tokens),
68 ..Self::default()
69 }
70 }
71
72 pub fn wall_clock(mut self, wall_clock: Duration) -> Self {
74 self.wall_clock = Some(wall_clock);
75 self
76 }
77
78 pub fn invocations(mut self, invocations: u32) -> Self {
80 self.invocations = Some(invocations);
81 self
82 }
83}
84
85pub struct StepContext<'a> {
88 pub outputs: &'a BTreeMap<String, Vec<u8>>,
89}
90
91#[async_trait::async_trait]
97pub trait StepFn: Send + Sync {
98 async fn build(&self, ctx: &StepContext<'_>) -> Result<Vec<u8>, LaserError>;
99}
100
101#[async_trait::async_trait]
102impl<F> StepFn for F
103where
104 F: Fn(&StepContext<'_>) -> Vec<u8> + Send + Sync,
105{
106 async fn build(&self, ctx: &StepContext<'_>) -> Result<Vec<u8>, LaserError> {
107 Ok(self(ctx))
108 }
109}
110
111#[async_trait::async_trait]
117pub trait Verifier: Send + Sync {
118 async fn verify(&self, output: &[u8]) -> Result<bool, LaserError>;
119}
120
121#[async_trait::async_trait]
122impl<F> Verifier for F
123where
124 F: Fn(&[u8]) -> bool + Send + Sync,
125{
126 async fn verify(&self, output: &[u8]) -> Result<bool, LaserError> {
127 Ok(self(output))
128 }
129}
130
131#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
133pub enum OnTimeout {
134 #[default]
136 Fail,
137 Reassign,
141}
142
143enum StepDispatch {
146 Completed { body: Vec<u8>, tokens: u64 },
147 TimedOut,
148 NotCompleted,
149}
150
151impl StepDispatch {
152 fn completed(self, on_failure: &str) -> Result<(Vec<u8>, u64), LaserError> {
155 match self {
156 StepDispatch::Completed { body, tokens } => Ok((body, tokens)),
157 _ => Err(LaserError::HandlerConfig(on_failure.to_owned())),
158 }
159 }
160}
161
162impl From<Contract> for StepDispatch {
163 fn from(outcome: Contract) -> Self {
164 match outcome {
165 Contract::Completed(reply) => {
166 let tokens = reply
167 .envelope
168 .as_ref()
169 .and_then(|envelope| envelope.usage)
170 .map_or(0, |usage| usage.input_tokens + usage.output_tokens);
171 StepDispatch::Completed {
172 body: reply.body().to_vec(),
173 tokens,
174 }
175 }
176 Contract::TimedOut => StepDispatch::TimedOut,
177 Contract::Failed(_) | Contract::NotConsumed => StepDispatch::NotCompleted,
178 }
179 }
180}
181
182struct Step {
183 label: String,
184 target: Router,
185 run: Box<dyn StepFn>,
186 after: Vec<String>,
187 verifier: Option<Box<dyn Verifier>>,
188 exclusive: bool,
189 fence_namespace: Option<String>,
190 on_timeout: OnTimeout,
191 compensate: Option<Box<dyn StepFn>>,
192}
193
194pub struct Workflow<'a> {
204 laser: &'a Laser,
205 name: String,
206 budget: WorkflowBudget,
207 inbox_route: InboxRoute,
208 run_id: Option<ConversationId>,
209 steps: Vec<Step>,
210}
211
212impl<'a> Workflow<'a> {
213 pub fn budget(mut self, budget: WorkflowBudget) -> Self {
215 self.budget = budget;
216 self
217 }
218
219 pub fn inbox_route(mut self, inbox_route: InboxRoute) -> Self {
224 self.inbox_route = inbox_route;
225 self
226 }
227
228 pub fn run_id(mut self, run_id: ConversationId) -> Self {
233 self.run_id = Some(run_id);
234 self
235 }
236
237 pub fn step(
242 mut self,
243 label: &str,
244 target: Router,
245 run: impl StepFn + 'static,
246 ) -> StepHandle<'a> {
247 self.steps.push(Step {
248 label: label.to_owned(),
249 target,
250 run: Box::new(run),
251 after: Vec::new(),
252 verifier: None,
253 exclusive: false,
254 fence_namespace: None,
255 on_timeout: OnTimeout::Fail,
256 compensate: None,
257 });
258 let current = self.steps.len() - 1;
259 StepHandle {
260 workflow: self,
261 current,
262 }
263 }
264
265 #[tracing::instrument(target = "laser", level = "info", skip_all, fields(agent = %self.name, operation = "workflow"))]
282 pub async fn run(mut self) -> Result<WorkflowOutcome, LaserError> {
283 let source: AgentId = self.name.parse().map_err(|_| {
284 LaserError::Invalid(format!(
285 "workflow name `{}` is not a valid agent id",
286 self.name
287 ))
288 })?;
289 let run_id = self.run_id.unwrap_or_default();
290 self.run_id = Some(run_id);
291 let mut builder = self
292 .laser
293 .sessions()
294 .start()
295 .with_id(run_id)
296 .agent(source.wire_id());
297 if let Some(tokens) = self.budget.tokens {
298 builder = builder.budget(laser_wire::agent::Budget {
299 tokens: Some(tokens),
300 cost_micros: None,
301 });
302 }
303 let (session, lease) = builder.begin().await?;
304 let result = self.execute(&session).await;
305 let ended = match &result {
306 Ok(_) => session.end().await,
307 Err(LaserError::Cancelled { .. }) => session.cancel().await,
308 Err(LaserError::BudgetExceeded { ceiling, spent }) => {
309 session
310 .fail_over_budget(&BudgetBreach::exceeded(*ceiling, *spent))
311 .await
312 }
313 Err(error) => {
314 session
315 .fail(laser_wire::agent::AgentErrorBody {
316 code: laser_wire::agent::AgentErrorCode::Internal,
317 message: Some(error.to_string()),
318 retryable: false,
319 detail: None,
320 })
321 .await
322 }
323 };
324 drop(lease);
325 match (result, ended) {
326 (Ok(outcome), Ok(())) => Ok(outcome),
327 (Ok(_), Err(error)) => Err(error),
328 (Err(error), _) => Err(error),
329 }
330 }
331
332 async fn execute(
335 &self,
336 session: &crate::agent::Session,
337 ) -> Result<WorkflowOutcome, LaserError> {
338 let order = topological_order(&self.steps)?;
339 let source: AgentId = self.name.parse().map_err(|_| {
340 LaserError::Invalid(format!(
341 "workflow name `{}` is not a valid agent id",
342 self.name
343 ))
344 })?;
345 let run_id = self.run_id.unwrap_or_default();
346
347 let started = Instant::now();
348 let mut outputs = self.replay(run_id).await?;
351 let mut completed: Vec<usize> = order
352 .iter()
353 .copied()
354 .filter(|&index| outputs.contains_key(&self.steps[index].label))
355 .collect();
356 let mut tokens_spent: u64 = 0;
357 let mut invocations: u32 = 0;
358
359 for index in order {
360 let step = &self.steps[index];
361 if outputs.contains_key(&step.label) {
362 continue;
364 }
365 if session.cancel_requested().await? {
366 self.compensate(&completed, &outputs).await;
367 return Err(LaserError::Cancelled {
368 run: run_id.to_string(),
369 });
370 }
371 if let Some(breach) = session.budget_breach().await? {
372 self.compensate(&completed, &outputs).await;
373 return Err(breach.into_error());
374 }
375 if matches!(step.target, Router::Broadcast) {
376 self.compensate(&completed, &outputs).await;
377 return Err(LaserError::Invalid(
378 "a broadcast workflow step has no gather target".to_owned(),
379 ));
380 }
381 if let Some(total) = self.budget.wall_clock
385 && total.saturating_sub(started.elapsed()) < STEP_BUDGET_FLOOR
386 {
387 self.compensate(&completed, &outputs).await;
388 return Err(LaserError::BudgetExceeded {
389 ceiling: total.as_micros().min(u128::from(u64::MAX)) as u64,
390 spent: started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
391 });
392 }
393 if step.exclusive && matches!(step.target, Router::AllCapable(_)) {
398 self.compensate(&completed, &outputs).await;
399 return Err(LaserError::Invalid(
400 "an exclusive step must be directed (to / to_capable)".to_owned(),
401 ));
402 }
403 if !step.exclusive && step.on_timeout == OnTimeout::Reassign {
404 self.compensate(&completed, &outputs).await;
405 return Err(LaserError::Invalid(
406 "on_timeout(Reassign) needs an exclusive step, so the reassignment \
407 bumps the fence and the timed-out holder is gated out"
408 .to_owned(),
409 ));
410 }
411
412 invocations += 1;
413 if let Err(error) = self.check_budget(invocations, tokens_spent, started) {
414 self.compensate(&completed, &outputs).await;
415 return Err(error);
416 }
417
418 let payload = match step.run.build(&StepContext { outputs: &outputs }).await {
419 Ok(payload) => payload,
420 Err(error) => {
421 self.compensate(&completed, &outputs).await;
422 return Err(error);
423 }
424 };
425 let dispatched = match &step.target {
431 Router::AllCapable(selector) => self
432 .scatter(&source, selector, &payload, started)
433 .await
434 .map(|(output, tokens)| (output, tokens, false)),
435 _ if step.exclusive => self
436 .dispatch_exclusive(&source, step, &payload, started, run_id)
437 .await
438 .map(|(output, tokens)| (output, tokens, true)),
439 _ => self
440 .dispatch_one(&source, step, payload, started, run_id)
441 .await
442 .and_then(|outcome| outcome.completed("a workflow step did not complete"))
443 .map(|(output, tokens)| (output, tokens, false)),
444 };
445 let (output, step_tokens, finalized) = match dispatched {
446 Ok(result) => result,
447 Err(error) => {
448 self.compensate(&completed, &outputs).await;
449 return Err(error);
450 }
451 };
452 tokens_spent = tokens_spent.saturating_add(step_tokens);
453
454 if !finalized && let Err(error) = self.finalize_step(run_id, step, &output).await {
455 self.compensate(&completed, &outputs).await;
456 return Err(error);
457 }
458 outputs.insert(step.label.clone(), output);
459 completed.push(index);
460 if let Err(error) = self.check_budget(invocations, tokens_spent, started) {
461 self.compensate(&completed, &outputs).await;
462 return Err(error);
463 }
464 }
465
466 Ok(WorkflowOutcome { outputs, run_id })
467 }
468
469 async fn finalize_step(
470 &self,
471 run_id: ConversationId,
472 step: &Step,
473 output: &[u8],
474 ) -> Result<(), LaserError> {
475 if let Some(verifier) = &step.verifier
476 && !verifier.verify(output).await?
477 {
478 return Err(LaserError::HandlerConfig(format!(
479 "workflow step `{}` failed verification",
480 step.label
481 )));
482 }
483 self.journal(run_id, &step.label, output).await
484 }
485
486 async fn dispatch_one(
490 &self,
491 source: &AgentId,
492 step: &Step,
493 payload: Vec<u8>,
494 started: Instant,
495 run_id: ConversationId,
496 ) -> Result<StepDispatch, LaserError> {
497 let child = ConversationId::derive(&format!("{run_id}/{}/1", step.label));
500 let outcome = self
501 .laser
502 .contract(step.target.clone())
503 .from(source.clone())
504 .payload(payload)
505 .inbox_route(self.inbox_route.clone())
506 .deadline(self.step_deadline(started))
507 .conversation(child)
508 .parent(run_id, run_id)
509 .send()
510 .await?;
511 Ok(StepDispatch::from(outcome))
512 }
513
514 async fn dispatch_exclusive(
522 &self,
523 source: &AgentId,
524 step: &Step,
525 payload: &[u8],
526 started: Instant,
527 run_id: ConversationId,
528 ) -> Result<(Vec<u8>, u64), LaserError> {
529 let task_conversation = ConversationId::derive(&format!("{run_id}/{}", step.label));
530 let max_attempts = match step.on_timeout {
531 OnTimeout::Reassign => MAX_REASSIGNMENTS.saturating_add(1),
532 OnTimeout::Fail => 1,
533 };
534 let mut attempt = 0;
535 loop {
536 attempt += 1;
537 let outcome = self
538 .dispatch_exclusive_attempt(
539 source,
540 step,
541 payload,
542 started,
543 run_id,
544 task_conversation,
545 attempt,
546 )
547 .await?;
548 match outcome {
549 StepDispatch::Completed { body, tokens } => return Ok((body, tokens)),
550 StepDispatch::TimedOut if attempt < max_attempts => continue,
551 _ => {
552 return Err(LaserError::HandlerConfig(
553 "an exclusive workflow step did not complete".to_owned(),
554 ));
555 }
556 }
557 }
558 }
559
560 #[cfg(feature = "kv")]
566 #[allow(clippy::too_many_arguments)]
567 async fn dispatch_exclusive_attempt(
568 &self,
569 source: &AgentId,
570 step: &Step,
571 payload: &[u8],
572 started: Instant,
573 run_id: ConversationId,
574 task_conversation: ConversationId,
575 attempt: u32,
576 ) -> Result<StepDispatch, LaserError> {
577 if !self.laser.current_capabilities().kv.fenced_leases {
578 return Err(LaserError::unsupported_feature(
579 "workflow",
580 "kv_fenced_leases",
581 "an exclusive step needs the managed backend's monotonic fence sequence",
582 ));
583 }
584 let namespace = step
585 .fence_namespace
586 .as_deref()
587 .unwrap_or(WORKFLOW_FENCE_NAMESPACE);
588 let key = run_id.to_string();
589 let holder = workflow_lease_holder(run_id, attempt);
590 let kv = self.laser.kv(namespace);
591 let coordination = self.laser.coordination_client().await?;
592 let mut lease = kv.lease(&key, holder.clone(), WORKFLOW_LEASE_TTL).await?;
593 let fence_token = lease.token;
594 let mut completion = Box::pin(async {
595 let outcome = self
596 .laser
597 .contract(step.target.clone())
598 .from(source.clone())
599 .payload(payload.to_vec())
600 .inbox_route(self.inbox_route.clone())
601 .deadline(self.step_deadline(started))
602 .fence(fence_token)
603 .conversation(task_conversation)
604 .parent(run_id, run_id)
605 .send()
606 .await?;
607 let dispatch = StepDispatch::from(outcome);
608 if let StepDispatch::Completed { body, .. } = &dispatch {
609 self.finalize_step(run_id, step, body).await?;
610 }
611 Ok::<_, LaserError>(dispatch)
612 });
613 let mut lease_expires_at = Instant::now() + lease.granted_ttl;
614 let outcome = 'maintain: loop {
615 let renewal_delay = workflow_renewal_delay(lease.granted_ttl)
616 .ok_or_else(|| LaserError::from(laser_wire::kv::KvError::LeaseLost))?;
617 tokio::select! {
618 result = &mut completion => break result,
619 () = tokio::time::sleep(renewal_delay) => {
620 let request = KvLeaseRenew {
621 v: KV_LEASE_OP_VERSION,
622 namespace: kv.resource_namespace().to_owned(),
623 key: key.as_bytes().to_vec(),
624 holder_id: holder.clone(),
625 subject_user_id: None,
626 lease_token: lease.token,
627 lease_ttl_micros: WORKFLOW_LEASE_TTL.as_micros() as u64,
628 };
629 let prepared = coordination.prepare_renew(&request)?;
630 let renewal_bound = lease_expires_at
631 .saturating_duration_since(Instant::now())
632 .min(self.step_deadline(started));
633 if renewal_bound.is_zero() {
634 break Err(laser_wire::kv::KvError::LeaseLost.into());
635 }
636 let mut renewal = Box::pin(
637 coordination.renew_with_timeout(&prepared, renewal_bound),
638 );
639 let renewed = tokio::select! {
640 result = &mut completion => {
641 let renewal_result = (&mut renewal).await;
642 if let Err(error) = renewal_result {
643 break 'maintain Err(error);
644 }
645 break 'maintain result;
646 }
647 result = &mut renewal => result,
648 };
649 match renewed {
650 Ok(next) => {
651 lease = next;
652 lease_expires_at = Instant::now() + lease.granted_ttl;
653 }
654 Err(error) => break Err(error),
655 }
656 }
657 }
658 };
659 let released = kv.release(&key, holder, lease.token).await;
660 let outcome = match outcome {
661 Ok(outcome) => outcome,
662 Err(error) => {
663 let _ = released;
664 return Err(error);
665 }
666 };
667 if !released? {
668 return Err(laser_wire::kv::KvError::LeaseLost.into());
669 }
670 Ok(outcome)
671 }
672
673 #[cfg(not(feature = "kv"))]
676 #[allow(clippy::too_many_arguments)]
677 async fn dispatch_exclusive_attempt(
678 &self,
679 _source: &AgentId,
680 _step: &Step,
681 _payload: &[u8],
682 _started: Instant,
683 _run_id: ConversationId,
684 _task_conversation: ConversationId,
685 _attempt: u32,
686 ) -> Result<StepDispatch, LaserError> {
687 Err(LaserError::unsupported_feature(
688 "workflow",
689 "kv_fenced_leases",
690 "exclusive workflow steps need the managed lease and fence",
691 ))
692 }
693
694 async fn scatter(
700 &self,
701 source: &AgentId,
702 selector: &CapabilitySelector,
703 payload: &[u8],
704 started: Instant,
705 ) -> Result<(Vec<u8>, u64), LaserError> {
706 let bodies = self
707 .laser
708 .scatter(
709 source.clone(),
710 selector,
711 payload,
712 &self.inbox_route,
713 self.step_deadline(started),
714 )
715 .await?;
716 if bodies.is_empty() {
717 return Err(LaserError::HandlerConfig(
718 "no capable agent completed the all-capable step".to_owned(),
719 ));
720 }
721 let folded = bodies
723 .iter()
724 .map(|body| String::from_utf8_lossy(body).into_owned())
725 .collect::<Vec<_>>()
726 .join("\n");
727 Ok((folded.into_bytes(), 0))
728 }
729
730 async fn replay(
734 &self,
735 run_id: ConversationId,
736 ) -> Result<BTreeMap<String, Vec<u8>>, LaserError> {
737 let records = ContextAssembler::builder()
738 .conversation_id(run_id)
739 .topics(vec![AgentTopic::WorkflowJournal])
740 .build()
741 .assemble(self.laser)
742 .await?;
743 let mut outputs = BTreeMap::new();
744 for record in records {
745 if let Ok(WorkflowJournalEntry::StepCompleted { label, output }) =
746 decode_named::<WorkflowJournalEntry>(&record.payload)
747 {
748 outputs.insert(label, output);
749 }
750 }
751 Ok(outputs)
752 }
753
754 async fn journal(
757 &self,
758 run_id: ConversationId,
759 label: &str,
760 output: &[u8],
761 ) -> Result<(), LaserError> {
762 let entry = WorkflowJournalEntry::StepCompleted {
763 label: label.to_owned(),
764 output: output.to_vec(),
765 };
766 let payload = encode_named(&entry)
767 .map_err(|error| LaserError::Codec(format!("encode journal entry: {error}")))?;
768 let provenance = Provenance::builder().conversation_id(run_id).build();
769 self.laser
770 .send_agent(AgentTopic::WorkflowJournal, payload, &provenance)
771 .await
772 }
773
774 async fn compensate(&self, completed: &[usize], outputs: &BTreeMap<String, Vec<u8>>) {
779 for &index in completed.iter().rev() {
780 let step = &self.steps[index];
781 let Some(compensate) = &step.compensate else {
782 continue;
783 };
784 let Ok(source) = self.name.parse::<AgentId>() else {
785 return;
786 };
787 let run_id = self.run_id.unwrap_or_default();
788 let Ok(payload) = compensate.build(&StepContext { outputs }).await else {
791 continue;
792 };
793 let _ = self
794 .laser
795 .contract(step.target.clone())
796 .from(source)
797 .payload(payload)
798 .inbox_route(self.inbox_route.clone())
799 .deadline(Duration::from_secs(30))
800 .conversation(ConversationId::derive(&format!(
801 "{run_id}/{}/compensate",
802 step.label
803 )))
804 .parent(run_id, run_id)
805 .send()
806 .await;
807 }
808 }
809
810 fn step_deadline(&self, started: Instant) -> Duration {
814 let default = Duration::from_secs(30);
815 match self.budget.wall_clock {
816 Some(total) => total.saturating_sub(started.elapsed()).min(default),
819 None => default,
820 }
821 }
822
823 fn check_budget(
825 &self,
826 invocations: u32,
827 tokens_spent: u64,
828 started: Instant,
829 ) -> Result<(), LaserError> {
830 if let Some(ceiling) = self.budget.invocations
831 && invocations > ceiling
832 {
833 return Err(LaserError::BudgetExceeded {
834 ceiling: u64::from(ceiling),
835 spent: u64::from(invocations),
836 });
837 }
838 if let Some(ceiling) = self.budget.tokens
839 && tokens_spent > ceiling
840 {
841 return Err(LaserError::BudgetExceeded {
842 ceiling,
843 spent: tokens_spent,
844 });
845 }
846 if let Some(ceiling) = self.budget.wall_clock
847 && started.elapsed() > ceiling
848 {
849 return Err(LaserError::BudgetExceeded {
850 ceiling: ceiling.as_micros().min(u128::from(u64::MAX)) as u64,
851 spent: started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
852 });
853 }
854 Ok(())
855 }
856}
857
858#[cfg(feature = "kv")]
859fn workflow_lease_holder(run_id: ConversationId, attempt: u32) -> String {
860 format!("workflow:{run_id}:{attempt}")
861}
862
863#[cfg(feature = "kv")]
864fn workflow_renewal_delay(granted_ttl: Duration) -> Option<Duration> {
865 let delay = granted_ttl / 2;
866 (!delay.is_zero()).then_some(delay)
867}
868
869pub struct StepHandle<'a> {
872 workflow: Workflow<'a>,
873 current: usize,
874}
875
876impl<'a> StepHandle<'a> {
877 pub fn after(mut self, label: &str) -> Self {
879 self.workflow.steps[self.current]
880 .after
881 .push(label.to_owned());
882 self
883 }
884
885 pub fn verify_with(mut self, verifier: impl Verifier + 'static) -> Self {
887 self.workflow.steps[self.current].verifier = Some(Box::new(verifier));
888 self
889 }
890
891 pub fn exclusive(mut self) -> Self {
893 self.workflow.steps[self.current].exclusive = true;
894 self
895 }
896
897 pub fn exclusive_in(mut self, namespace: impl Into<String>) -> Self {
905 let step = &mut self.workflow.steps[self.current];
906 step.exclusive = true;
907 step.fence_namespace = Some(namespace.into());
908 self
909 }
910
911 pub fn on_timeout(mut self, on_timeout: OnTimeout) -> Self {
915 self.workflow.steps[self.current].on_timeout = on_timeout;
916 self
917 }
918
919 pub fn compensate_with(mut self, run: impl StepFn + 'static) -> Self {
921 self.workflow.steps[self.current].compensate = Some(Box::new(run));
922 self
923 }
924
925 pub fn budget(mut self, budget: WorkflowBudget) -> Self {
927 self.workflow.budget = budget;
928 self
929 }
930
931 pub fn inbox_route(mut self, inbox_route: InboxRoute) -> Self {
933 self.workflow.inbox_route = inbox_route;
934 self
935 }
936
937 pub fn run_id(mut self, run_id: ConversationId) -> Self {
939 self.workflow.run_id = Some(run_id);
940 self
941 }
942
943 pub fn step(self, label: &str, target: Router, run: impl StepFn + 'static) -> StepHandle<'a> {
945 self.workflow.step(label, target, run)
946 }
947
948 pub async fn run(self) -> Result<WorkflowOutcome, LaserError> {
950 self.workflow.run().await
951 }
952}
953
954#[derive(Debug)]
957pub struct WorkflowOutcome {
958 pub outputs: BTreeMap<String, Vec<u8>>,
959 pub run_id: ConversationId,
960}
961
962impl Laser {
963 pub fn workflow(&self, name: &str) -> Workflow<'_> {
967 Workflow {
968 laser: self,
969 name: name.to_owned(),
970 budget: WorkflowBudget::unlimited(),
971 inbox_route: InboxRoute::default(),
972 run_id: None,
973 steps: Vec::new(),
974 }
975 }
976}
977
978fn topological_order(steps: &[Step]) -> Result<Vec<usize>, LaserError> {
981 let index_of: BTreeMap<&str, usize> = steps
982 .iter()
983 .enumerate()
984 .map(|(index, step)| (step.label.as_str(), index))
985 .collect();
986 let mut in_degree = vec![0usize; steps.len()];
987 let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); steps.len()];
988 for (index, step) in steps.iter().enumerate() {
989 for dependency in &step.after {
990 let &dep = index_of.get(dependency.as_str()).ok_or_else(|| {
991 LaserError::Invalid(format!(
992 "workflow step `{}` depends on unknown step `{dependency}`",
993 step.label
994 ))
995 })?;
996 in_degree[index] += 1;
997 dependents[dep].push(index);
998 }
999 }
1000 let mut ready: Vec<usize> = (0..steps.len()).filter(|&i| in_degree[i] == 0).collect();
1003 let mut order = Vec::with_capacity(steps.len());
1004 let mut cursor = 0;
1005 while cursor < ready.len() {
1006 let index = ready[cursor];
1007 cursor += 1;
1008 order.push(index);
1009 for &dependent in &dependents[index] {
1010 in_degree[dependent] -= 1;
1011 if in_degree[dependent] == 0 {
1012 ready.push(dependent);
1013 }
1014 }
1015 }
1016 if order.len() != steps.len() {
1017 return Err(LaserError::Invalid(
1018 "workflow steps form a dependency cycle".to_owned(),
1019 ));
1020 }
1021 Ok(order)
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027
1028 fn step(label: &str, after: &[&str]) -> Step {
1029 Step {
1030 label: label.to_owned(),
1031 target: Router::broadcast(),
1032 run: Box::new(|_: &StepContext<'_>| Vec::new()),
1033 after: after.iter().map(|s| s.to_owned().to_owned()).collect(),
1034 verifier: None,
1035 exclusive: false,
1036 fence_namespace: None,
1037 on_timeout: OnTimeout::Fail,
1038 compensate: None,
1039 }
1040 }
1041
1042 fn labels(steps: &[Step], order: &[usize]) -> Vec<String> {
1043 order.iter().map(|&i| steps[i].label.clone()).collect()
1044 }
1045
1046 #[test]
1047 fn given_a_linear_chain_when_ordered_then_should_follow_the_dependencies() {
1048 let steps = vec![
1049 step("rollback", &["diagnose"]),
1050 step("triage", &[]),
1051 step("diagnose", &["triage"]),
1052 ];
1053 let order = topological_order(&steps).expect("a chain is acyclic");
1054 assert_eq!(labels(&steps, &order), ["triage", "diagnose", "rollback"]);
1055 }
1056
1057 #[test]
1058 fn given_independent_steps_when_ordered_then_should_keep_the_authored_sequence() {
1059 let steps = vec![step("a", &[]), step("b", &[]), step("c", &[])];
1060 let order = topological_order(&steps).expect("independent steps are acyclic");
1061 assert_eq!(labels(&steps, &order), ["a", "b", "c"]);
1062 }
1063
1064 #[test]
1065 fn given_a_cycle_when_ordered_then_should_error() {
1066 let steps = vec![step("a", &["b"]), step("b", &["a"])];
1067 let error = topological_order(&steps).unwrap_err();
1068 assert!(matches!(error, LaserError::Invalid(message) if message.contains("cycle")));
1069 }
1070
1071 #[cfg(feature = "kv")]
1072 #[test]
1073 fn given_reassignments_when_holders_are_derived_then_should_use_distinct_bounded_identities() {
1074 let run_id = ConversationId::new();
1075 let first = workflow_lease_holder(run_id, 1);
1076 let second = workflow_lease_holder(run_id, 2);
1077 assert_ne!(first, second);
1078 assert!(first.len() <= laser_wire::limits::MAX_HOLDER_ID_BYTES);
1079 assert!(second.len() <= laser_wire::limits::MAX_HOLDER_ID_BYTES);
1080 }
1081
1082 #[cfg(feature = "kv")]
1083 #[test]
1084 fn given_a_short_granted_ttl_when_scheduling_renewal_then_should_fail_before_expiry() {
1085 assert_eq!(workflow_renewal_delay(Duration::from_nanos(1)), None);
1086 assert_eq!(
1087 workflow_renewal_delay(Duration::from_secs(60)),
1088 Some(Duration::from_secs(30))
1089 );
1090 }
1091
1092 #[test]
1093 fn given_an_unknown_dependency_when_ordered_then_should_error() {
1094 let steps = vec![step("a", &["ghost"])];
1095 let error = topological_order(&steps).unwrap_err();
1096 assert!(matches!(error, LaserError::Invalid(message) if message.contains("unknown step")));
1097 }
1098}