Skip to main content

laser_sdk/agent/
workflow.rs

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/// One workflow-journal record. The engine records a step's outcome so a crashed
18/// run resumes by replaying the journal rather than re-executing. Replay
19/// re-derives the recorded output, it does not re-run the step. Keyed on the log
20/// by the run's conversation id, so one journal topic carries every run.
21#[derive(Serialize, Deserialize)]
22enum WorkflowJournalEntry {
23    StepCompleted { label: String, output: Vec<u8> },
24}
25
26/// The key-value namespace holding the workflow run's fenced lease, the
27/// orchestrator-fence an exclusive step claims.
28#[cfg(feature = "kv")]
29pub const WORKFLOW_FENCE_NAMESPACE: &str = "agdx.workflow.fence";
30
31/// How long an exclusive step's fenced lease is held before it must be renewed.
32#[cfg(feature = "kv")]
33const WORKFLOW_LEASE_TTL: Duration = Duration::from_secs(60);
34
35/// The least wall-clock a step is worth dispatching with. A budget with less than
36/// this remaining is treated as spent, so the workflow breaches as a budget
37/// exceeded rather than handing the step a near-zero deadline that surfaces as a
38/// generic timeout.
39const STEP_BUDGET_FLOOR: Duration = Duration::from_millis(100);
40
41/// The most times an exclusive step is reassigned on timeout before it fails.
42const MAX_REASSIGNMENTS: u32 = 2;
43
44/// A spend ceiling for a workflow, summed from the advisory `usage` on replies and
45/// the engine's own counters. Any unset dimension is unbounded. A breach is a
46/// [`LaserError::BudgetExceeded`], read from the engine's running totals.
47#[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    /// An unbounded budget (the default).
56    pub fn unlimited() -> Self {
57        Self::default()
58    }
59
60    /// Cap the summed input-plus-output tokens across all step replies. Only
61    /// counts the `usage` a step's reply carries, which an AGDX `respond` sets
62    /// with `with_usage`. A plain `send_agent` reply (no envelope) and an
63    /// all-capable scatter report zero, so this ceiling is advisory and bites only
64    /// where handlers attach usage.
65    pub fn tokens(tokens: u64) -> Self {
66        Self {
67            tokens: Some(tokens),
68            ..Self::default()
69        }
70    }
71
72    /// Cap the wall-clock time the whole workflow may run.
73    pub fn wall_clock(mut self, wall_clock: Duration) -> Self {
74        self.wall_clock = Some(wall_clock);
75        self
76    }
77
78    /// Cap how many steps the workflow may dispatch.
79    pub fn invocations(mut self, invocations: u32) -> Self {
80        self.invocations = Some(invocations);
81        self
82    }
83}
84
85/// The prior steps' outputs, by label, handed to a [`StepFn`] so it can build this
86/// step's payload from what came before.
87pub struct StepContext<'a> {
88    pub outputs: &'a BTreeMap<String, Vec<u8>>,
89}
90
91/// Builds a step's task payload from the outputs of the steps before it. Async
92/// and fallible, so a step that must call a model or a service to build its input
93/// can, and a build failure fails the step rather than panicking. A plain
94/// synchronous closure `Fn(&StepContext) -> Vec<u8>` still is a `StepFn` (the
95/// blanket impl wraps it), so the common step stays a one-line closure.
96#[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/// Checks a step's reply before it counts as done. Async and fallible, so an
112/// LLM-judge verifier panel (the advertised use) can call a model and a check
113/// can say why it failed, not just that it did. A `false` verdict fails the step
114/// (and triggers compensation). A plain synchronous closure `Fn(&[u8]) -> bool`
115/// still is a `Verifier` via the blanket impl.
116#[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/// How a step responds to its contract timing out without a terminal reply.
132#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
133pub enum OnTimeout {
134    /// Fail the step and run compensations (the default).
135    #[default]
136    Fail,
137    /// Hand the task to a fresh holder after releasing the timed-out assignment's
138    /// lease. The fresh acquisition bumps the fence and gates the old holder out at
139    /// the sink. Needs an exclusive step so reassignment cannot double-commit.
140    Reassign,
141}
142
143/// One directed dispatch's outcome, with a timeout kept distinct from other
144/// non-completions so an exclusive step can reassign on it.
145enum StepDispatch {
146    Completed { body: Vec<u8>, tokens: u64 },
147    TimedOut,
148    NotCompleted,
149}
150
151impl StepDispatch {
152    /// The completing reply's body and tokens, or a step failure with `on_failure`
153    /// for a timeout or other non-completion (the no-reassign collapse).
154    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
194/// A journalled directed-acyclic workflow over the built coordination primitives.
195/// Each step is a directed request (the task [`contract`](Laser::contract)) to its
196/// `target`, ordered by its declared dependencies, with an optional verifier,
197/// exclusivity, and a compensation. Built by [`Laser::workflow`], described with
198/// fluent [`step`](Self::step) calls, and driven by [`run`](Self::run).
199///
200/// The single decision the developer makes per step is whether it has an exclusive
201/// side effect (`.exclusive()`). Everything else (lease, fence, journal, replay)
202/// is the engine's, never a word in a user-facing signature.
203pub 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    /// Set the workflow's spend ceiling.
214    pub fn budget(mut self, budget: WorkflowBudget) -> Self {
215        self.budget = budget;
216        self
217    }
218
219    /// How each step resolves its target to a topic (default
220    /// [`InboxRoute::Advertised`]). The same flexible addressing every directed
221    /// send uses, so a deployment without a presence command sets
222    /// [`InboxRoute::Fixed`].
223    pub fn inbox_route(mut self, inbox_route: InboxRoute) -> Self {
224        self.inbox_route = inbox_route;
225        self
226    }
227
228    /// Resume an earlier run by its id (from a prior [`WorkflowOutcome::run_id`]):
229    /// the engine replays that run's journal and skips the steps already recorded
230    /// complete, re-dispatching only the unfinished ones. A fresh run mints its own
231    /// id, so omit this to start anew.
232    pub fn run_id(mut self, run_id: ConversationId) -> Self {
233        self.run_id = Some(run_id);
234        self
235    }
236
237    /// Add a step `label` that dispatches to `target`, building its payload from
238    /// the prior outputs with `run`. Returns a [`StepHandle`] so the step can be
239    /// refined (`after`, `verify_with`, `exclusive`, `compensate_with`) before the
240    /// next `step`.
241    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    /// Run the workflow to completion, returning each step's output. Steps run in
266    /// dependency order. A step's reply that fails its verifier, or a dispatch that
267    /// does not complete, fails the workflow after running the completed steps'
268    /// compensations in reverse (the saga rollback).
269    ///
270    /// The run is a session on `agent.sessions` whose id is the run id, and every
271    /// step and compensation is a child session of it. The run session starts
272    /// before the first step and ends by the outcome. Between steps the engine
273    /// checks `agent.control` for a cancel request, which compensates and
274    /// returns [`LaserError::Cancelled`], and then whether the run session is
275    /// [over its budget](crate::agent::Session::over_budget), which
276    /// compensates and returns [`LaserError::BudgetExceeded`]. Any budget
277    /// breach ends the run session failed with reason `budget`. A lifecycle
278    /// record that fails to
279    /// publish surfaces as the error, except when the run itself already
280    /// failed, where the run's error wins.
281    #[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    // The engine body: steps in dependency order, a cancel check on the run
333    // session at every step boundary.
334    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        // Replay the journal: a step recorded complete keeps its recorded output
349        // and is not re-dispatched (replay re-derives, never re-executes).
350        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                // Recorded complete on a prior run, skip it.
363                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            // A near-spent wall-clock budget would hand the step a near-zero
382            // deadline that surfaces as a generic timeout. Treat it as the budget
383            // breach it is, before claiming any lease.
384            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            // Only a directed step can be exclusive (a scatter has no single target
394            // to fence), and reassign-on-timeout needs an exclusive step, since the
395            // fence is what makes handing the task to a fresh holder safe from the
396            // timed-out one double-executing.
397            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            // A directed step is one contract. An exclusive step claims a fenced
426            // lease, pins a task conversation stable across re-dispatch, and on a
427            // timeout may reassign (re-leasing bumps the fence, gating the stale
428            // holder). An all-capable step scatters to every capable agent and
429            // folds their replies. All return the step output and reply tokens.
430            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    /// Dispatch a directed step as one contract. A completion returns its body and
487    /// reply tokens. A timeout is surfaced distinctly so an exclusive step can
488    /// reassign. Any other non-completion fails the step.
489    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        // The step's child session id derives from the run, so a resumed run
498        // reaches the same child instead of starting another.
499        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    /// Dispatch an exclusive step: claim a fenced lease, stamp its token on a
515    /// command pinned to a task conversation stable across re-dispatch, and send.
516    /// The lease is renewed through verification and completion journaling, then
517    /// released on every exit. On a timeout with [`OnTimeout::Reassign`], a new
518    /// holder then acquires the lease, bumping the fence before re-dispatch, up to
519    /// a bounded number of assignments. Returns the completing reply's body and
520    /// tokens after its journal record is durable.
521    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    /// Claim, maintain, and release one assignment's lease. Renewal runs at half
561    /// of the granted TTL while the contract, verification, and completion journal
562    /// are active. Every exit releases the lease, so a timeout reassignment can
563    /// acquire immediately under a fresh holder and bump the fence sequence before
564    /// the stale task can commit.
565    #[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    /// Without the managed key-value store there is no lease to fence with, so an
674    /// exclusive step cannot run.
675    #[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    /// Scatter a contract to every agent advertising the selector's skill,
695    /// concurrently, and fold the completed replies (each body on its own line) into
696    /// the step output. The step succeeds when at least one capable agent completes,
697    /// the partial-panel tolerance a verifier or diagnostic pool wants. No capable
698    /// agent at all is a [`LaserError::NoCapableAgent`].
699    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        // Fold each completed reply onto its own line for the next step.
722        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    /// Replay this run's journal into the completed-step outputs, so a resumed run
731    /// skips what already finished. Reads the workflow-journal topic for the run's
732    /// conversation and folds each recorded outcome.
733    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    /// Record a step's completion on the journal topic, keyed by the run's
755    /// conversation, so a later resume replays it instead of re-dispatching.
756    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    /// Run the compensations of the completed steps in reverse order (the saga
775    /// rollback). Best-effort: a compensation that the step did not declare is
776    /// skipped, and a compensating dispatch failure is not surfaced over the
777    /// original error.
778    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            // Compensation is best-effort: a build failure skips this one rather
789            // than aborting the reverse-order saga unwind.
790            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    /// The deadline for one step's dispatch: whatever wall-clock budget remains,
811    /// clamped to a sensible per-step default so a step without a budget still
812    /// bounds its wait.
813    fn step_deadline(&self, started: Instant) -> Duration {
814        let default = Duration::from_secs(30);
815        match self.budget.wall_clock {
816            // Use whatever wall-clock remains, never longer than the per-step
817            // default. A fully-spent budget is caught by the budget check first.
818            Some(total) => total.saturating_sub(started.elapsed()).min(default),
819            None => default,
820        }
821    }
822
823    /// Check the running totals against the budget, erroring on the first breach.
824    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
869/// A step under construction, returned by [`Workflow::step`]. Refines the step it
870/// was returned for, then chains the next `step` or `run`.
871pub struct StepHandle<'a> {
872    workflow: Workflow<'a>,
873    current: usize,
874}
875
876impl<'a> StepHandle<'a> {
877    /// Declare that this step runs only after `label` has completed.
878    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    /// Check this step's reply with `verifier` before it counts as done.
886    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    /// Opt this step into the lease and fence (an exclusive, at-most-once effect).
892    pub fn exclusive(mut self) -> Self {
893        self.workflow.steps[self.current].exclusive = true;
894        self
895    }
896
897    /// Opt this step into a lease and fence whose sequence lives in
898    /// `namespace`. Use the same namespace for the handler's
899    /// [`Kv::cas_fenced`](crate::kv::Kv::cas_fenced) commit and pass the run id
900    /// as its fence key, so the external effect and the workflow reassignment
901    /// validate one fence sequence atomically. Plain [`exclusive`](Self::exclusive)
902    /// keeps the SDK-owned coordination namespace for consumers that only need
903    /// the local stale-holder gate.
904    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    /// Set what the step does when its contract times out (default
912    /// [`OnTimeout::Fail`]). [`OnTimeout::Reassign`] hands the task to a fresh
913    /// holder with a bumped fence, and requires [`exclusive`](Self::exclusive).
914    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    /// Provide the compensation to run if a later step fails (the saga rollback).
920    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    /// Set the workflow budget (forwards to [`Workflow::budget`]).
926    pub fn budget(mut self, budget: WorkflowBudget) -> Self {
927        self.workflow.budget = budget;
928        self
929    }
930
931    /// Set the inbox route (forwards to [`Workflow::inbox_route`]).
932    pub fn inbox_route(mut self, inbox_route: InboxRoute) -> Self {
933        self.workflow.inbox_route = inbox_route;
934        self
935    }
936
937    /// Resume an earlier run (forwards to [`Workflow::run_id`]).
938    pub fn run_id(mut self, run_id: ConversationId) -> Self {
939        self.workflow.run_id = Some(run_id);
940        self
941    }
942
943    /// Add the next step (forwards to [`Workflow::step`]).
944    pub fn step(self, label: &str, target: Router, run: impl StepFn + 'static) -> StepHandle<'a> {
945        self.workflow.step(label, target, run)
946    }
947
948    /// Run the workflow (forwards to [`Workflow::run`]).
949    pub async fn run(self) -> Result<WorkflowOutcome, LaserError> {
950        self.workflow.run().await
951    }
952}
953
954/// The result of a completed [`Workflow::run`]: each step's output by label, plus
955/// the run id. Pass the run id to [`Workflow::run_id`] to resume the same run.
956#[derive(Debug)]
957pub struct WorkflowOutcome {
958    pub outputs: BTreeMap<String, Vec<u8>>,
959    pub run_id: ConversationId,
960}
961
962impl Laser {
963    /// Open a [`Workflow`] named `name` (also the orchestrator identity it
964    /// dispatches as). Describe it with fluent [`step`](Workflow::step) calls, then
965    /// [`run`](Workflow::run).
966    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
978/// Order the steps so every step follows its `after` dependencies (Kahn's
979/// algorithm). Errors on an unknown dependency or a cycle.
980fn 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    // Seed with the dependency-free steps in declared order, so an independent
1001    // chain keeps its authored sequence.
1002    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}