1use super::combinators::{execute_pipeline, execute_steps_parallel_resumable, PipelineStage};
47use super::executor::{execute_steps_parallel, AgentExecutor, AgentStepSpec, StepOutcome};
48use super::workflow_budget::{BudgetSnapshot, WorkflowBudget};
49use crate::agent::AgentEvent;
50use crate::store::SessionStore;
51use serde::{Deserialize, Serialize};
52use std::sync::atomic::{AtomicUsize, Ordering};
53use std::sync::Arc;
54use tokio::sync::broadcast;
55
56const DEFAULT_WORKFLOW_EVENT_CAPACITY: usize = 256;
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67#[serde(tag = "type", rename_all = "snake_case")]
68pub enum WorkflowEvent {
69 PhaseStart {
71 name: String,
72 index: usize,
73 step_count: usize,
74 },
75 PhaseEnd {
77 name: String,
78 index: usize,
79 succeeded: usize,
80 failed: usize,
81 },
82 Log {
84 level: String,
85 message: String,
86 fields: serde_json::Value,
87 },
88 BudgetExhausted { resource: String, reason: String },
92}
93
94pub struct WorkflowBuilder {
97 executor: Arc<dyn AgentExecutor>,
98 store: Option<Arc<dyn SessionStore>>,
99 step_events: Option<broadcast::Sender<AgentEvent>>,
100 root_id: Option<String>,
101 budget: Option<Arc<WorkflowBudget>>,
102}
103
104impl WorkflowBuilder {
105 pub fn new(executor: Arc<dyn AgentExecutor>) -> Self {
108 Self {
109 executor,
110 store: None,
111 step_events: None,
112 root_id: None,
113 budget: None,
114 }
115 }
116
117 pub fn with_budget(mut self, budget: Arc<WorkflowBudget>) -> Self {
125 self.budget = Some(budget);
126 self
127 }
128
129 pub fn with_store(mut self, store: Arc<dyn SessionStore>) -> Self {
132 self.store = Some(store);
133 self
134 }
135
136 pub fn with_step_events(mut self, step_events: broadcast::Sender<AgentEvent>) -> Self {
139 self.step_events = Some(step_events);
140 self
141 }
142
143 pub fn with_root_id(mut self, root_id: impl Into<String>) -> Self {
147 self.root_id = Some(root_id.into());
148 self
149 }
150
151 pub fn build(self) -> Workflow {
153 let (events, _) = broadcast::channel(DEFAULT_WORKFLOW_EVENT_CAPACITY);
154 let root_id = self
155 .root_id
156 .unwrap_or_else(|| format!("wf-{}", uuid::Uuid::new_v4()));
157 Workflow {
158 executor: self.executor,
159 store: self.store,
160 events,
161 step_events: self.step_events,
162 root_id,
163 phase_seq: Arc::new(AtomicUsize::new(0)),
164 budget: self.budget,
165 }
166 }
167}
168
169#[derive(Clone)]
175pub struct Workflow {
176 executor: Arc<dyn AgentExecutor>,
177 store: Option<Arc<dyn SessionStore>>,
178 events: broadcast::Sender<WorkflowEvent>,
179 step_events: Option<broadcast::Sender<AgentEvent>>,
180 root_id: String,
181 phase_seq: Arc<AtomicUsize>,
182 budget: Option<Arc<WorkflowBudget>>,
183}
184
185impl Workflow {
186 pub fn builder(executor: Arc<dyn AgentExecutor>) -> WorkflowBuilder {
188 WorkflowBuilder::new(executor)
189 }
190
191 pub fn root_id(&self) -> &str {
193 &self.root_id
194 }
195
196 pub fn budget_snapshot(&self) -> Option<BudgetSnapshot> {
198 self.budget.as_ref().map(|b| b.snapshot())
199 }
200
201 pub fn subscribe(&self) -> broadcast::Receiver<WorkflowEvent> {
203 self.events.subscribe()
204 }
205
206 pub async fn agent(&self, spec: AgentStepSpec) -> StepOutcome {
210 let task_id = spec.task_id.clone();
211 let agent = spec.agent.clone();
212 execute_steps_parallel(
213 Arc::clone(&self.executor),
214 vec![spec],
215 self.step_events.clone(),
216 )
217 .await
218 .into_iter()
219 .next()
220 .unwrap_or_else(|| StepOutcome::failed(task_id, agent, "step produced no outcome"))
221 }
222
223 pub async fn parallel(&self, specs: Vec<AgentStepSpec>) -> Vec<StepOutcome> {
227 execute_steps_parallel(Arc::clone(&self.executor), specs, self.step_events.clone()).await
228 }
229
230 pub async fn phase(&self, name: &str, specs: Vec<AgentStepSpec>) -> Vec<StepOutcome> {
238 let index = self.phase_seq.fetch_add(1, Ordering::SeqCst);
239 let _ = self.events.send(WorkflowEvent::PhaseStart {
240 name: name.to_string(),
241 index,
242 step_count: specs.len(),
243 });
244
245 let out = match &self.store {
246 Some(store) => {
247 let workflow_id = format!("{}/{index}:{name}", self.root_id);
248 execute_steps_parallel_resumable(
249 Arc::clone(&self.executor),
250 specs,
251 &workflow_id,
252 Arc::clone(store),
253 self.step_events.clone(),
254 )
255 .await
256 }
257 None => self.parallel(specs).await,
258 };
259
260 let failed = out.iter().filter(|o| !o.success).count();
261 let _ = self.events.send(WorkflowEvent::PhaseEnd {
262 name: name.to_string(),
263 index,
264 succeeded: out.len() - failed,
265 failed,
266 });
267
268 if let Some(budget) = &self.budget {
272 if budget.is_exhausted() {
273 let snap = budget.snapshot();
274 let _ = self.events.send(WorkflowEvent::BudgetExhausted {
275 resource: "workflow_tokens".to_string(),
276 reason: format!(
277 "workflow token budget exhausted ({} / {} tokens)",
278 snap.consumed_tokens,
279 snap.limit_tokens.unwrap_or(0)
280 ),
281 });
282 }
283 }
284 out
285 }
286
287 pub async fn pipeline<I>(
291 &self,
292 items: Vec<I>,
293 stages: Vec<PipelineStage<I>>,
294 ) -> Vec<Option<StepOutcome>>
295 where
296 I: Send + 'static,
297 {
298 execute_pipeline(
299 Arc::clone(&self.executor),
300 items,
301 stages,
302 self.step_events.clone(),
303 )
304 .await
305 }
306
307 pub fn log(&self, level: &str, message: &str, fields: serde_json::Value) {
311 let _ = self.events.send(WorkflowEvent::Log {
312 level: level.to_string(),
313 message: message.to_string(),
314 fields,
315 });
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::orchestration::WorkflowCheckpoint;
323 use crate::store::MemorySessionStore;
324 use async_trait::async_trait;
325 use std::collections::HashMap;
326
327 struct EchoExecutor {
329 ran: Arc<tokio::sync::Mutex<Vec<String>>>,
330 }
331
332 impl EchoExecutor {
333 fn new() -> Self {
334 Self {
335 ran: Arc::new(tokio::sync::Mutex::new(Vec::new())),
336 }
337 }
338 }
339
340 #[async_trait]
341 impl AgentExecutor for EchoExecutor {
342 async fn execute_step(
343 &self,
344 spec: AgentStepSpec,
345 _event_tx: Option<broadcast::Sender<AgentEvent>>,
346 ) -> StepOutcome {
347 self.ran.lock().await.push(spec.task_id.clone());
348 StepOutcome {
349 task_id: spec.task_id.clone(),
350 session_id: format!("task-run-{}", spec.task_id),
351 agent: spec.agent.clone(),
352 output: spec.prompt.clone(),
353 success: spec.agent != "fail",
354 structured: None,
355 source_anchors: Vec::new(),
356 }
357 }
358 fn concurrency_hint(&self) -> usize {
359 4
360 }
361 }
362
363 fn spec(id: &str, agent: &str, prompt: &str) -> AgentStepSpec {
364 AgentStepSpec::new(id, agent, "d", prompt)
365 }
366
367 #[tokio::test]
368 async fn agent_runs_a_single_step() {
369 let wf = Workflow::builder(Arc::new(EchoExecutor::new())).build();
370 let out = wf.agent(spec("a", "explore", "hello")).await;
371 assert_eq!(out.task_id, "a");
372 assert_eq!(out.output, "hello");
373 assert!(out.success);
374 }
375
376 #[tokio::test]
377 async fn parallel_preserves_order_and_isolates_failure() {
378 let wf = Workflow::builder(Arc::new(EchoExecutor::new())).build();
379 let out = wf
380 .parallel(vec![
381 spec("a", "explore", "pa"),
382 spec("b", "fail", "pb"),
383 spec("c", "review", "pc"),
384 ])
385 .await;
386 assert_eq!(
387 out.iter().map(|o| o.task_id.as_str()).collect::<Vec<_>>(),
388 vec!["a", "b", "c"]
389 );
390 assert!(out[0].success);
391 assert!(
392 !out[1].success,
393 "the failing branch surfaces as success=false"
394 );
395 assert!(out[2].success);
396 }
397
398 #[tokio::test]
399 async fn phase_emits_start_and_end_milestones() {
400 let wf = Workflow::builder(Arc::new(EchoExecutor::new())).build();
401 let mut rx = wf.subscribe();
402 let out = wf
403 .phase(
404 "review",
405 vec![spec("a", "review", "p"), spec("b", "fail", "p")],
406 )
407 .await;
408 assert_eq!(out.len(), 2);
409
410 let start = rx.recv().await.unwrap();
411 assert_eq!(
412 start,
413 WorkflowEvent::PhaseStart {
414 name: "review".to_string(),
415 index: 0,
416 step_count: 2,
417 }
418 );
419 let end = rx.recv().await.unwrap();
420 assert_eq!(
421 end,
422 WorkflowEvent::PhaseEnd {
423 name: "review".to_string(),
424 index: 0,
425 succeeded: 1,
426 failed: 1,
427 }
428 );
429 }
430
431 #[tokio::test]
432 async fn phases_get_monotonic_indices() {
433 let wf = Workflow::builder(Arc::new(EchoExecutor::new())).build();
434 let mut rx = wf.subscribe();
435 wf.phase("one", vec![spec("a", "explore", "p")]).await;
436 wf.phase("two", vec![spec("b", "explore", "p")]).await;
437
438 let indices: Vec<usize> = {
439 let mut seen = Vec::new();
440 while let Ok(ev) = rx.try_recv() {
441 if let WorkflowEvent::PhaseStart { index, .. } = ev {
442 seen.push(index);
443 }
444 }
445 seen
446 };
447 assert_eq!(indices, vec![0, 1], "phase indices increment per call");
448 }
449
450 #[tokio::test]
451 async fn phase_with_store_resumes_from_checkpoint() {
452 let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
453 let exec = Arc::new(EchoExecutor::new());
454 let ran = Arc::clone(&exec.ran);
455 let wf = Workflow::builder(exec)
456 .with_store(Arc::clone(&store))
457 .with_root_id("root")
458 .build();
459
460 let mut done = HashMap::new();
463 done.insert(
464 "a".to_string(),
465 StepOutcome {
466 task_id: "a".into(),
467 session_id: "task-run-a".into(),
468 agent: "explore".into(),
469 output: "cached-a".into(),
470 success: true,
471 structured: None,
472 source_anchors: Vec::new(),
473 },
474 );
475 store
476 .save_workflow_checkpoint(
477 "root/0:implement",
478 &WorkflowCheckpoint::from_completed("root/0:implement", &done, 1),
479 )
480 .await
481 .unwrap();
482
483 let out = wf
484 .phase(
485 "implement",
486 vec![spec("a", "explore", "pa"), spec("b", "review", "pb")],
487 )
488 .await;
489
490 assert_eq!(
491 *ran.lock().await,
492 vec!["b".to_string()],
493 "only the not-yet-completed step actually runs"
494 );
495 assert_eq!(
496 out[0].output, "cached-a",
497 "completed step reuses its cached outcome"
498 );
499 assert!(out.iter().all(|o| o.success));
500 }
501
502 #[tokio::test]
503 async fn pipeline_chains_stages_per_item() {
504 let wf = Workflow::builder(Arc::new(EchoExecutor::new())).build();
505 let stages: Vec<PipelineStage<&str>> = vec![
506 Arc::new(|_prev: Option<&StepOutcome>, item: &&str| {
507 Some(AgentStepSpec::new("s1", "explore", "d", *item))
508 }),
509 Arc::new(|prev: Option<&StepOutcome>, _item: &&str| {
510 let prior = prev.map(|o| o.output.clone()).unwrap_or_default();
511 Some(AgentStepSpec::new(
512 "s2",
513 "review",
514 "d",
515 format!("review of: {prior}"),
516 ))
517 }),
518 ];
519 let out = wf.pipeline(vec!["alpha", "beta"], stages).await;
520 assert_eq!(out.len(), 2);
521 assert_eq!(out[0].as_ref().unwrap().output, "review of: alpha");
522 assert_eq!(out[1].as_ref().unwrap().output, "review of: beta");
523 }
524
525 #[tokio::test]
526 async fn log_emits_a_log_event() {
527 let wf = Workflow::builder(Arc::new(EchoExecutor::new())).build();
528 let mut rx = wf.subscribe();
529 wf.log("info", "hello", serde_json::json!({ "k": 1 }));
530 let ev = rx.recv().await.unwrap();
531 assert_eq!(
532 ev,
533 WorkflowEvent::Log {
534 level: "info".to_string(),
535 message: "hello".to_string(),
536 fields: serde_json::json!({ "k": 1 }),
537 }
538 );
539 }
540
541 #[tokio::test]
542 async fn phase_emits_budget_exhausted_when_capped() {
543 use crate::budget::BudgetGuard;
544 use crate::llm::TokenUsage;
545
546 let budget = Arc::new(WorkflowBudget::new(Some(10)));
547 budget
550 .record_after_llm(
551 "s",
552 &TokenUsage {
553 total_tokens: 12,
554 ..Default::default()
555 },
556 )
557 .await;
558
559 let wf = Workflow::builder(Arc::new(EchoExecutor::new()))
560 .with_budget(Arc::clone(&budget))
561 .build();
562 let mut rx = wf.subscribe();
563 wf.phase("p", vec![spec("a", "explore", "p")]).await;
564
565 let mut saw_exhausted = false;
566 while let Ok(ev) = rx.try_recv() {
567 if let WorkflowEvent::BudgetExhausted { resource, .. } = ev {
568 assert_eq!(resource, "workflow_tokens");
569 saw_exhausted = true;
570 }
571 }
572 assert!(
573 saw_exhausted,
574 "phase boundary emits BudgetExhausted once capped"
575 );
576 assert_eq!(wf.budget_snapshot().unwrap().consumed_tokens, 12);
577 }
578
579 #[tokio::test]
580 async fn no_budget_means_no_snapshot_and_no_exhausted_event() {
581 let wf = Workflow::builder(Arc::new(EchoExecutor::new())).build();
582 let mut rx = wf.subscribe();
583 wf.phase("p", vec![spec("a", "explore", "p")]).await;
584 assert!(wf.budget_snapshot().is_none());
585 while let Ok(ev) = rx.try_recv() {
586 assert!(
587 !matches!(ev, WorkflowEvent::BudgetExhausted { .. }),
588 "no budget → never a BudgetExhausted event"
589 );
590 }
591 }
592
593 #[tokio::test]
594 async fn agent_returns_failed_outcome_when_executor_yields_nothing() {
595 struct Empty;
598 #[async_trait]
599 impl AgentExecutor for Empty {
600 async fn execute_step(
601 &self,
602 spec: AgentStepSpec,
603 _tx: Option<broadcast::Sender<AgentEvent>>,
604 ) -> StepOutcome {
605 StepOutcome::failed(spec.task_id, spec.agent, "boom")
606 }
607 }
608 let wf = Workflow::builder(Arc::new(Empty)).build();
609 let out = wf.agent(spec("x", "explore", "p")).await;
610 assert_eq!(out.task_id, "x");
611 assert!(!out.success);
612 }
613}