1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::{
5 atomic::{AtomicBool, Ordering},
6 Arc, Weak,
7};
8use std::time::Duration;
9
10use async_trait::async_trait;
11use bamboo_agent_core::tools::{
12 FunctionCall, ToolCall, ToolExecutionContext, ToolExecutionSessionFlags, ToolExecutor,
13 ToolOutcome, ToolResult,
14};
15use bamboo_domain::{
16 validate_schema, CompiledWorkflow, FailurePolicy, StartWorkflowRun, ValueRef,
17 WorkflowBudgetUsage, WorkflowBudgets, WorkflowCompileError, WorkflowDefinitionBundle,
18 WorkflowFailure, WorkflowFailureCode, WorkflowPlan, WorkflowProgress, WorkflowRunDefinition,
19 WorkflowRunEvent, WorkflowRunEventKind, WorkflowRunSnapshot, WorkflowRunStatus,
20 WorkflowStepDefinition, WorkflowStepKind, WorkflowStepSnapshot, WorkflowStepStatus,
21 WorkflowSuspensionContext,
22};
23use chrono::Utc;
24use dashmap::{mapref::entry::Entry, DashMap};
25use futures::{future::join_all, stream::FuturesUnordered, StreamExt};
26use serde_json::Value;
27use sha2::{Digest, Sha256};
28use thiserror::Error;
29use tokio::sync::{broadcast, Mutex, Semaphore};
30use tokio_util::sync::CancellationToken;
31use uuid::Uuid;
32
33use super::repository::WorkflowRunRepository;
34
35type SecretResolutionFuture<'a> =
36 Pin<Box<dyn Future<Output = Result<(Value, Vec<String>), WorkflowFailure>> + Send + 'a>>;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct NamedAgentSpec {
40 pub name: String,
41 pub allowed_capabilities: BTreeSet<String>,
42 pub profile:
43 Option<Arc<crate::session_app::child_session::named_profile::ResolvedChildProfile>>,
44 pub cost_supported: bool,
45}
46
47#[derive(Debug, Clone)]
48pub struct AgentStepResult {
49 pub output: Value,
50 pub tokens: u64,
51 pub cost_micros: Option<u64>,
52 pub failure: Option<WorkflowFailure>,
54 pub attempt_id: Option<String>,
56}
57
58#[async_trait]
59pub trait AgentStepPort: Send + Sync {
60 async fn resolve(&self, name: &str, session_id: &str)
62 -> Result<Option<NamedAgentSpec>, String>;
63 async fn execute(
64 &self,
65 spec: &NamedAgentSpec,
66 prompt: Value,
67 model: Option<&str>,
68 effort: Option<&str>,
69 capabilities: &BTreeSet<String>,
70 session_id: &str,
71 root_run_id: &str,
72 cancellation: CancellationToken,
73 ) -> Result<AgentStepResult, String>;
74 fn acknowledge_result(&self, _attempt_id: &str) -> bool {
76 true
77 }
78 async fn drain_cancelled(
80 &self,
81 _root_run_id: &str,
82 ) -> (Vec<Result<AgentStepResult, String>>, Option<String>) {
83 (Vec::new(), None)
84 }
85}
86
87#[async_trait]
88pub trait WorkflowDefinitionPort: Send + Sync {
89 async fn pin_bundle(
93 &self,
94 root: &WorkflowRunDefinition,
95 ) -> Result<WorkflowDefinitionBundle, String>;
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum PermissionDecision {
100 Allow,
101 Deny(String),
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum WorkflowPolicyTarget {
106 Tool(String),
107 Agent(String),
108 Workflow { id: String, revision: u64 },
109}
110
111#[async_trait]
112pub trait WorkflowPolicyPort: Send + Sync {
113 async fn authorize(
114 &self,
115 session_id: &str,
116 target: &WorkflowPolicyTarget,
117 requested: &BTreeSet<String>,
118 workspace_trusted: bool,
119 ) -> PermissionDecision;
120}
121
122#[async_trait]
123pub trait WorkflowSessionPermissionPort: Send + Sync {
124 async fn flags_for_session(
125 &self,
126 session_id: &str,
127 ) -> Result<ToolExecutionSessionFlags, String>;
128}
129
130pub struct WorkflowSecretMaterial(String);
134
135impl WorkflowSecretMaterial {
136 pub fn new(value: String) -> Self {
137 Self(value)
138 }
139
140 fn into_exposed(self) -> String {
141 self.0
142 }
143}
144
145#[async_trait]
146pub trait WorkflowSecretResolverPort: Send + Sync {
147 async fn resolve(
148 &self,
149 session_id: &str,
150 capability: &str,
151 ) -> Result<WorkflowSecretMaterial, String>;
152}
153
154#[derive(Debug, Error)]
155pub enum WorkflowRunError {
156 #[error(transparent)]
157 Compile(#[from] WorkflowCompileError),
158 #[error("invalid workflow input: {0}")]
159 InvalidInput(String),
160 #[error("workflow preflight failed: {0}")]
161 Preflight(String),
162 #[error("named agents without monetary measurement do not support a finite monetary budget")]
163 UnsupportedMonetaryBudget,
164 #[error("workflow storage failed: {0}")]
165 Storage(String),
166 #[error("workflow run not found")]
167 NotFound,
168 #[error("workflow run is already terminal")]
169 Terminal,
170}
171
172pub struct WorkflowRunEngine {
173 repository: Arc<dyn WorkflowRunRepository>,
174 tools: Arc<dyn ToolExecutor>,
175 agents: Arc<dyn AgentStepPort>,
176 definitions: Arc<dyn WorkflowDefinitionPort>,
177 policy: Arc<dyn WorkflowPolicyPort>,
178 secrets: Arc<dyn WorkflowSecretResolverPort>,
179 ceilings: WorkflowBudgets,
180 active: DashMap<String, Arc<ActiveRun>>,
181 events: DashMap<String, broadcast::Sender<WorkflowRunEvent>>,
182 commit_fences: DashMap<String, Weak<Mutex<()>>>,
185 session_permissions: std::sync::RwLock<Option<Arc<dyn WorkflowSessionPermissionPort>>>,
186}
187
188struct ActiveRun {
189 session_id: String,
190 cancellation: CancellationToken,
191 snapshot: Arc<Mutex<WorkflowRunSnapshot>>,
192 root_run_id: String,
193 ledger: Arc<Mutex<WorkflowBudgetUsage>>,
194 drain_lock: Arc<Mutex<()>>,
195 cleanup_pending: AtomicBool,
196 has_agents: bool,
197}
198
199struct RuntimeRegistration {
200 engine: Weak<WorkflowRunEngine>,
201 run_id: String,
202}
203
204impl Drop for RuntimeRegistration {
205 fn drop(&mut self) {
206 if let Some(engine) = self.engine.upgrade() {
207 let pending = engine
208 .active
209 .get(&self.run_id)
210 .is_some_and(|run| run.cleanup_pending.load(Ordering::Acquire));
211 if !pending {
212 engine.active.remove(&self.run_id);
213 engine.events.remove(&self.run_id);
214 engine
217 .commit_fences
218 .retain(|_, fence| fence.strong_count() > 0);
219 }
220 }
221 }
222}
223
224struct RunContext {
225 engine: Arc<WorkflowRunEngine>,
226 compiled: Arc<CompiledWorkflow>,
227 bundle: Arc<WorkflowDefinitionBundle>,
228 pinned_agents: Arc<HashMap<String, NamedAgentSpec>>,
229 snapshot: Arc<Mutex<WorkflowRunSnapshot>>,
230 cancellation: CancellationToken,
231 branch_cancellation: CancellationToken,
232 allowed_capabilities: BTreeSet<String>,
233 workspace_trusted: bool,
234 semaphore: Arc<Semaphore>,
235 items: HashMap<String, Value>,
236 scope: String,
237 map_index_path: Vec<usize>,
238 depth: u32,
239 ledger: Arc<Mutex<WorkflowBudgetUsage>>,
240 root_limits: WorkflowBudgets,
241 root_run_id: String,
242 drain_lock: Arc<Mutex<()>>,
243}
244
245impl Clone for RunContext {
246 fn clone(&self) -> Self {
247 Self {
248 engine: self.engine.clone(),
249 compiled: self.compiled.clone(),
250 bundle: self.bundle.clone(),
251 pinned_agents: self.pinned_agents.clone(),
252 snapshot: self.snapshot.clone(),
253 cancellation: self.cancellation.clone(),
254 branch_cancellation: self.branch_cancellation.clone(),
255 allowed_capabilities: self.allowed_capabilities.clone(),
256 workspace_trusted: self.workspace_trusted,
257 semaphore: self.semaphore.clone(),
258 items: self.items.clone(),
259 scope: self.scope.clone(),
260 map_index_path: self.map_index_path.clone(),
261 depth: self.depth,
262 ledger: self.ledger.clone(),
263 root_limits: self.root_limits.clone(),
264 root_run_id: self.root_run_id.clone(),
265 drain_lock: self.drain_lock.clone(),
266 }
267 }
268}
269
270type NodeFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, WorkflowFailure>> + Send + 'a>>;
271type StartSignal =
272 Arc<Mutex<Option<tokio::sync::oneshot::Sender<Result<WorkflowRunSnapshot, WorkflowRunError>>>>>;
273
274impl WorkflowRunEngine {
275 pub fn new(
276 repository: Arc<dyn WorkflowRunRepository>,
277 tools: Arc<dyn ToolExecutor>,
278 agents: Arc<dyn AgentStepPort>,
279 definitions: Arc<dyn WorkflowDefinitionPort>,
280 policy: Arc<dyn WorkflowPolicyPort>,
281 secrets: Arc<dyn WorkflowSecretResolverPort>,
282 ceilings: WorkflowBudgets,
283 ) -> Arc<Self> {
284 Arc::new(Self {
285 repository,
286 tools,
287 agents,
288 definitions,
289 policy,
290 secrets,
291 ceilings,
292 active: DashMap::new(),
293 events: DashMap::new(),
294 commit_fences: DashMap::new(),
295 session_permissions: std::sync::RwLock::new(None),
296 })
297 }
298
299 pub fn set_session_permission_port(&self, port: Arc<dyn WorkflowSessionPermissionPort>) {
300 *self
301 .session_permissions
302 .write()
303 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(port);
304 }
305
306 pub async fn run(
307 self: &Arc<Self>,
308 request: StartWorkflowRun,
309 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
310 let bundle = self.pin_and_validate_bundle(&request.definition).await?;
311 self.run_pinned(request, bundle).await
312 }
313
314 pub async fn run_pinned(
315 self: &Arc<Self>,
316 request: StartWorkflowRun,
317 bundle: WorkflowDefinitionBundle,
318 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
319 self.validate_bundle(&request.definition, &bundle)?;
320 let cancellation = CancellationToken::new();
321 let ledger = Arc::new(Mutex::new(WorkflowBudgetUsage::default()));
322 let limits = effective_limits(&request.definition.budgets, &self.ceilings);
323 let semaphore = Arc::new(Semaphore::new(limits.max_concurrency));
324 let pinned_agents = Arc::new(
325 self.preflight_bundle(
326 &bundle,
327 &request.session_id,
328 &request.allowed_capabilities.iter().cloned().collect(),
329 request.workspace_trusted,
330 &limits,
331 )
332 .await?,
333 );
334 self.run_internal(
335 request,
336 Arc::new(bundle),
337 pinned_agents,
338 None,
339 None,
340 0,
341 cancellation,
342 ledger,
343 limits,
344 semaphore,
345 None,
346 None,
347 )
348 .await
349 }
350
351 pub async fn start(
354 self: &Arc<Self>,
355 request: StartWorkflowRun,
356 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
357 let bundle = self.pin_and_validate_bundle(&request.definition).await?;
358 self.start_pinned(request, bundle).await
359 }
360
361 pub async fn start_pinned(
362 self: &Arc<Self>,
363 request: StartWorkflowRun,
364 bundle: WorkflowDefinitionBundle,
365 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
366 self.validate_bundle(&request.definition, &bundle)?;
367 let cancellation = CancellationToken::new();
368 let ledger = Arc::new(Mutex::new(WorkflowBudgetUsage::default()));
369 let limits = effective_limits(&request.definition.budgets, &self.ceilings);
370 let semaphore = Arc::new(Semaphore::new(limits.max_concurrency));
371 let pinned_agents = Arc::new(
372 self.preflight_bundle(
373 &bundle,
374 &request.session_id,
375 &request.allowed_capabilities.iter().cloned().collect(),
376 request.workspace_trusted,
377 &limits,
378 )
379 .await?,
380 );
381 let (tx, rx) = tokio::sync::oneshot::channel();
382 let signal = Arc::new(Mutex::new(Some(tx)));
383 let engine = self.clone();
384 tokio::spawn(async move {
385 let result = engine
386 .run_internal(
387 request,
388 Arc::new(bundle),
389 pinned_agents,
390 None,
391 None,
392 0,
393 cancellation,
394 ledger,
395 limits,
396 semaphore,
397 Some(signal.clone()),
398 None,
399 )
400 .await;
401 if let Err(error) = result {
402 if let Some(sender) = signal.lock().await.take() {
403 let _ = sender.send(Err(error));
404 } else {
405 tracing::error!("background workflow run failed after start");
406 }
407 } else if signal.lock().await.is_some() {
408 tracing::error!("workflow task completed without publishing a start snapshot");
409 }
410 });
411 rx.await.map_err(|_| {
412 WorkflowRunError::Storage("workflow task exited before durable start".to_string())
413 })?
414 }
415
416 pub async fn restart(
419 self: &Arc<Self>,
420 run_id: &str,
421 workspace_trusted: bool,
422 allowed_capabilities: Vec<String>,
423 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
424 let previous = self
425 .repository
426 .load(run_id)
427 .await
428 .map_err(storage)?
429 .ok_or(WorkflowRunError::NotFound)?;
430 if previous.status != WorkflowRunStatus::Suspended {
431 return Err(if previous.status.is_terminal() {
432 WorkflowRunError::Terminal
433 } else {
434 WorkflowRunError::Preflight("only suspended workflows can restart".to_string())
435 });
436 }
437 if matches!(
438 previous.suspension,
439 Some(
440 WorkflowSuspensionContext::ToolApproval { .. }
441 | WorkflowSuspensionContext::ToolRunning { .. }
442 )
443 ) {
444 return Err(WorkflowRunError::Preflight(
445 "workflow has durable suspension context and requires explicit resume handling"
446 .to_string(),
447 ));
448 }
449 let bundle = previous.definition_bundle;
450 self.start_pinned(
451 StartWorkflowRun {
452 definition: previous.definition,
453 args: previous.validated_args,
454 session_id: previous.session_id,
455 workspace_trusted,
456 allowed_capabilities,
457 },
458 bundle,
459 )
460 .await
461 }
462
463 pub fn completed_prefix(snapshot: &WorkflowRunSnapshot) -> Result<usize, WorkflowRunError> {
466 let refuse = || {
467 WorkflowRunError::Preflight("workflow has no safe completed-prefix checkpoint".into())
468 };
469 if snapshot.status != WorkflowRunStatus::Suspended
470 || !matches!(
471 snapshot.suspension,
472 Some(WorkflowSuspensionContext::Recovery { .. })
473 )
474 || snapshot.parent_run_id.is_some()
475 || snapshot.parent_step_id.is_some()
476 || snapshot.output.is_some()
477 || snapshot.failure.is_some()
478 {
479 return Err(refuse());
480 }
481 let bundle = &snapshot.definition_bundle;
482 if bundle.definitions.len() != 1
483 || bundle.root_id != snapshot.definition.id
484 || bundle.root_revision != snapshot.definition.revision
485 || bundle.root() != Some(&snapshot.definition)
486 || definition_bundle_hash(bundle)? != snapshot.definition_bundle_hash
487 {
488 return Err(refuse());
489 }
490 let compiled = CompiledWorkflow::compile(snapshot.definition.clone())?;
491 compiled
492 .validate_input(&snapshot.validated_args)
493 .map_err(WorkflowRunError::InvalidInput)?;
494 reject_secret_material(&snapshot.validated_args).map_err(WorkflowRunError::InvalidInput)?;
495 let serialized = serde_json::to_value(bundle)
496 .map_err(|error| WorkflowRunError::Preflight(error.to_string()))?;
497 reject_secret_material_in_definition(&serialized).map_err(WorkflowRunError::Preflight)?;
498 let WorkflowPlan::Sequence { nodes } = &snapshot.definition.plan else {
499 return Err(refuse());
500 };
501 let ids = nodes
502 .iter()
503 .map(|node| match node {
504 WorkflowPlan::Step { step } => Ok(step.as_str()),
505 _ => Err(refuse()),
506 })
507 .collect::<Result<Vec<_>, _>>()?;
508 if ids.is_empty()
509 || ids.len() != compiled.steps.len()
510 || ids.iter().copied().collect::<BTreeSet<_>>().len() != ids.len()
511 {
512 return Err(refuse());
513 }
514 let mut outputs = BTreeMap::new();
515 let mut prefix = 0;
516 let mut suffix = false;
517 for id in ids.iter().copied() {
518 let step = compiled.steps.get(id).ok_or_else(refuse)?;
519 let WorkflowStepKind::Tool {
520 args, capabilities, ..
521 } = &step.kind
522 else {
523 return Err(refuse());
524 };
525 if capabilities.iter().any(|capability| capability != "read")
526 || contains_secret_handle(args)
527 {
528 return Err(refuse());
529 }
530 let Some(state) = snapshot.steps.get(id) else {
531 suffix = true;
532 continue;
533 };
534 if suffix
535 || state.id != id
536 || state.status != WorkflowStepStatus::Succeeded
537 || state.attempts != 1
538 || state.failure.is_some()
539 {
540 return Err(refuse());
541 }
542 let input = resolve_checkpoint_template(args, &snapshot.validated_args, &outputs)?;
543 let hash = hex::encode(Sha256::digest(
544 serde_json::to_vec(&input)
545 .map_err(|error| WorkflowRunError::InvalidInput(error.to_string()))?,
546 ));
547 let output = state.output.as_ref().ok_or_else(refuse)?;
548 if hash != state.input_hash {
549 return Err(refuse());
550 }
551 if let Some(schema) = &step.output_schema {
552 validate_schema(schema, output).map_err(|_| refuse())?;
553 }
554 reject_secret_material(output).map_err(|_| refuse())?;
555 outputs.insert(id.to_string(), output.clone());
556 prefix += 1;
557 }
558 let usage = &snapshot.usage;
559 let budget = &snapshot.definition.budgets;
560 if prefix == 0
561 || snapshot.steps.len() != prefix
562 || usage.steps as usize != prefix
563 || usage.agents != 0
564 || usage.retries != 0
565 || ids.len() > budget.max_steps as usize
566 || budget.max_tokens.is_some_and(|limit| usage.tokens > limit)
567 || budget
568 .max_cost_micros
569 .is_some_and(|limit| usage.cost_micros.is_none_or(|cost| cost > limit))
570 || remaining_wall_time(snapshot) == 0
571 {
572 return Err(refuse());
573 }
574 Ok(prefix)
575 }
576
577 pub async fn continue_completed_prefix(
580 self: &Arc<Self>,
581 run_id: &str,
582 session_id: &str,
583 workspace_trusted: bool,
584 allowed_capabilities: Vec<String>,
585 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
586 let previous = self
587 .repository
588 .load(run_id)
589 .await
590 .map_err(storage)?
591 .ok_or(WorkflowRunError::NotFound)?;
592 if previous.run_id != run_id || previous.session_id != session_id {
593 return Err(WorkflowRunError::NotFound);
594 }
595 Self::completed_prefix(&previous)?;
596 let snapshot = Arc::new(Mutex::new(previous.clone()));
597 let cancellation = CancellationToken::new();
598 let ledger = Arc::new(Mutex::new(previous.usage.clone()));
599 let drain_lock = Arc::new(Mutex::new(()));
600 let active = Arc::new(ActiveRun {
601 session_id: session_id.to_string(),
602 cancellation: cancellation.clone(),
603 snapshot: snapshot.clone(),
604 root_run_id: run_id.to_string(),
605 ledger: ledger.clone(),
606 drain_lock: drain_lock.clone(),
607 cleanup_pending: AtomicBool::new(false),
608 has_agents: false,
609 });
610 match self.active.entry(run_id.to_string()) {
611 Entry::Occupied(_) => {
612 return Err(WorkflowRunError::Preflight(
613 "workflow continuation is already active".into(),
614 ))
615 }
616 Entry::Vacant(entry) => {
617 entry.insert(active);
618 }
619 }
620 let registration = RuntimeRegistration {
621 engine: Arc::downgrade(self),
622 run_id: run_id.to_string(),
623 };
624 let current = self
627 .repository
628 .load(run_id)
629 .await
630 .map_err(storage)?
631 .ok_or(WorkflowRunError::NotFound)?;
632 let prefix = Self::completed_prefix(¤t)?;
633 if current != previous {
634 return Err(WorkflowRunError::Preflight(
635 "workflow checkpoint changed during continuation admission".into(),
636 ));
637 }
638 self.validate_bundle(¤t.definition, ¤t.definition_bundle)?;
639 self.enforce_ceilings(¤t.definition.budgets)?;
640 let allowed_capabilities = allowed_capabilities.into_iter().collect::<BTreeSet<_>>();
641 let root_limits = effective_limits(¤t.definition.budgets, &self.ceilings);
642 self.preflight_bundle(
643 ¤t.definition_bundle,
644 session_id,
645 &allowed_capabilities,
646 workspace_trusted,
647 &root_limits,
648 )
649 .await?;
650 let permission_port = self
651 .session_permissions
652 .read()
653 .unwrap_or_else(std::sync::PoisonError::into_inner)
654 .clone();
655 if let Some(port) = permission_port {
656 port.flags_for_session(session_id).await.map_err(|_| {
657 WorkflowRunError::Preflight(
658 "workflow session permission posture is unavailable".into(),
659 )
660 })?;
661 }
662 Self::completed_prefix(¤t)?;
663 let compiled = Arc::new(CompiledWorkflow::compile(current.definition.clone())?);
664 let context = RunContext {
665 engine: self.clone(),
666 compiled,
667 bundle: Arc::new(current.definition_bundle),
668 pinned_agents: Arc::new(HashMap::new()),
669 snapshot: snapshot.clone(),
670 cancellation: cancellation.clone(),
671 branch_cancellation: cancellation.child_token(),
672 allowed_capabilities,
673 workspace_trusted,
674 semaphore: Arc::new(Semaphore::new(root_limits.max_concurrency)),
675 items: HashMap::new(),
676 scope: "root".into(),
677 map_index_path: Vec::new(),
678 depth: 0,
679 ledger,
680 root_limits,
681 root_run_id: run_id.to_string(),
682 drain_lock,
683 };
684 let (sender, _) = broadcast::channel(256);
685 self.events.insert(run_id.to_string(), sender);
686 let (tx, rx) = tokio::sync::oneshot::channel();
687 let engine = self.clone();
688 tokio::spawn(async move {
689 let _registration = registration;
690 let start = {
691 let mut snapshot = snapshot.lock().await;
692 if cancellation.is_cancelled() {
693 Err(WorkflowRunError::Preflight(
694 "workflow continuation was cancelled during admission".into(),
695 ))
696 } else {
697 engine
698 .transition(
699 &mut snapshot,
700 None,
701 WorkflowRunEventKind::RunStarted,
702 |snapshot| {
703 snapshot.status = WorkflowRunStatus::Running;
704 snapshot.suspension = None;
705 },
706 )
707 .await
708 .map(|()| snapshot.clone())
709 }
710 };
711 match start {
712 Ok(started) => {
713 let timeout_ms = remaining_wall_time(&started);
714 let _ = tx.send(Ok(started));
715 if engine
716 .execute_context(context, timeout_ms, prefix)
717 .await
718 .is_err()
719 {
720 tracing::error!(
721 "background workflow continuation failed after durable start"
722 );
723 }
724 }
725 Err(error) => {
726 let _ = tx.send(Err(error));
727 }
728 }
729 });
730 rx.await.map_err(|_| {
731 WorkflowRunError::Storage("workflow continuation exited before durable start".into())
732 })?
733 }
734
735 #[allow(clippy::too_many_arguments)]
736 async fn run_internal(
737 self: &Arc<Self>,
738 request: StartWorkflowRun,
739 bundle: Arc<WorkflowDefinitionBundle>,
740 pinned_agents: Arc<HashMap<String, NamedAgentSpec>>,
741 parent_run_id: Option<String>,
742 parent_step_id: Option<String>,
743 depth: u32,
744 cancellation: CancellationToken,
745 ledger: Arc<Mutex<WorkflowBudgetUsage>>,
746 root_limits: WorkflowBudgets,
747 semaphore: Arc<Semaphore>,
748 started: Option<StartSignal>,
749 root_run_id: Option<String>,
750 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
751 if request.definition.steps.len() > self.ceilings.max_steps as usize {
752 return Err(WorkflowRunError::Preflight(
753 "workflow definition exceeds server step-count ceiling".to_string(),
754 ));
755 }
756 let compiled = Arc::new(CompiledWorkflow::compile(request.definition)?);
757 self.enforce_ceilings(&compiled.definition.budgets)?;
758 let definition_value = serde_json::to_value(&compiled.definition)
759 .map_err(|error| WorkflowRunError::Preflight(error.to_string()))?;
760 reject_secret_material_in_definition(&definition_value)
761 .map_err(WorkflowRunError::Preflight)?;
762 compiled
763 .validate_input(&request.args)
764 .map_err(WorkflowRunError::InvalidInput)?;
765 reject_secret_material(&request.args).map_err(WorkflowRunError::InvalidInput)?;
766 let allowed_capabilities = request
767 .allowed_capabilities
768 .into_iter()
769 .collect::<BTreeSet<_>>();
770 enforce_budget_within(&compiled.definition.budgets, &root_limits).map_err(|message| {
771 WorkflowRunError::Preflight(format!("nested workflow budget expands root: {message}"))
772 })?;
773
774 let run_id = Uuid::new_v4().to_string();
775 let root_run_id = root_run_id.unwrap_or_else(|| run_id.clone());
776 let now = Utc::now();
777 let snapshot = WorkflowRunSnapshot {
778 run_id: run_id.clone(),
779 parent_run_id,
780 parent_step_id,
781 session_id: request.session_id,
782 definition: compiled.definition.clone(),
783 definition_bundle: bundle.as_ref().clone(),
784 definition_bundle_hash: definition_bundle_hash(&bundle)?,
785 validated_args: request.args,
786 status: WorkflowRunStatus::Queued,
787 steps: BTreeMap::new(),
788 usage: WorkflowBudgetUsage::default(),
789 last_sequence: 1,
790 output: None,
791 failure: None,
792 suspension: None,
793 created_at: now,
794 updated_at: now,
795 };
796 let queued = event(&snapshot, None, WorkflowRunEventKind::RunQueued);
797 let session_id = snapshot.session_id.clone();
798 self.repository
799 .create(&snapshot, &queued)
800 .await
801 .map_err(storage)?;
802 let (sender, _) = broadcast::channel(256);
803 self.events.insert(run_id.clone(), sender);
804 self.publish(&queued);
805 let snapshot = Arc::new(Mutex::new(snapshot));
806 let drain_lock = Arc::new(Mutex::new(()));
807 self.active.insert(
808 run_id.clone(),
809 Arc::new(ActiveRun {
810 session_id,
811 cancellation: cancellation.clone(),
812 snapshot: snapshot.clone(),
813 root_run_id: root_run_id.clone(),
814 ledger: ledger.clone(),
815 drain_lock: drain_lock.clone(),
816 cleanup_pending: AtomicBool::new(false),
817 has_agents: !pinned_agents.is_empty(),
818 }),
819 );
820 let _registration = RuntimeRegistration {
821 engine: Arc::downgrade(self),
822 run_id: run_id.clone(),
823 };
824 {
825 let mut shared = snapshot.lock().await;
826 let start_result = if cancellation.is_cancelled() {
827 self.finish_cancelled(&mut shared).await
828 } else {
829 self.transition(
830 &mut shared,
831 None,
832 WorkflowRunEventKind::RunStarted,
833 |snapshot| {
834 snapshot.status = WorkflowRunStatus::Running;
835 },
836 )
837 .await
838 };
839 start_result?;
840 if let Some(started) = started {
841 if let Some(sender) = started.lock().await.take() {
842 let _ = sender.send(Ok(shared.clone()));
843 }
844 }
845 }
846 let context = RunContext {
847 engine: self.clone(),
848 compiled: compiled.clone(),
849 bundle,
850 pinned_agents,
851 snapshot: snapshot.clone(),
852 cancellation: cancellation.clone(),
853 branch_cancellation: cancellation.child_token(),
854 allowed_capabilities,
855 workspace_trusted: request.workspace_trusted,
856 semaphore,
857 items: HashMap::new(),
858 scope: "root".to_string(),
859 map_index_path: Vec::new(),
860 depth,
861 ledger,
862 root_limits,
863 root_run_id: root_run_id.clone(),
864 drain_lock,
865 };
866 self.execute_context(context, compiled.definition.budgets.wall_time_ms, 0)
867 .await
868 }
869
870 async fn execute_context(
871 self: &Arc<Self>,
872 context: RunContext,
873 timeout_ms: u64,
874 completed_prefix: usize,
875 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
876 let compiled = &context.compiled;
877 let snapshot = &context.snapshot;
878 let cancellation = &context.cancellation;
879 let run_id = snapshot.lock().await.run_id.clone();
880 let result = tokio::time::timeout(
881 Duration::from_millis(timeout_ms),
882 context.execute_root(completed_prefix),
883 )
884 .await;
885 if result.is_err() {
886 cancellation.cancel();
887 }
888 let _drain_guard = context.drain_lock.lock().await;
889 {
890 let snapshot = snapshot.lock().await;
891 if snapshot.status.is_terminal() {
892 return Ok(snapshot.clone());
893 }
894 }
895 if result.is_err() {
896 if let Some(durable) = self.load_committed_snapshot(&run_id).await? {
897 *snapshot.lock().await = durable;
898 }
899 }
900 let (usage_unavailable, drain_error) = context.drain_agents().await;
901 if context.ledger.lock().await.agents > 0 {
903 context
904 .checkpoint_agent_usage("AgentDrained")
905 .await
906 .map_err(|e| WorkflowRunError::Storage(e.message))?;
907 }
908 if drain_error.is_some() {
909 if let Some(active) = self.active.get(&run_id) {
910 active.cleanup_pending.store(true, Ordering::Release);
911 }
912 return Err(WorkflowRunError::Preflight(
913 "Workflow children have not confirmed stop; cancellation can be retried".into(),
914 ));
915 }
916 let mut final_snapshot = snapshot.lock().await;
917 if usage_unavailable && !final_snapshot.status.is_terminal() {
918 self.finish_failed(&mut final_snapshot, failure(WorkflowFailureCode::ExecutionFailed,
919 "workflow Agent stopped but cumulative token observation unavailable; usage is not measured", false)).await?;
920 return Ok(final_snapshot.clone());
921 }
922 if final_snapshot.status.is_terminal() {
923 return Ok(final_snapshot.clone());
924 }
925 match result {
926 Ok(Ok(output)) if cancellation.is_cancelled() => {
927 let _ = output;
928 self.finish_cancelled(&mut final_snapshot).await?;
929 }
930 Ok(Ok(output)) => {
931 if let Some(schema) = &compiled.definition.output_schema {
932 if let Err(message) = validate_schema(schema, &output) {
933 let failure = failure(WorkflowFailureCode::InvalidOutput, message, false);
934 self.finish_failed(&mut final_snapshot, failure).await?;
935 } else {
936 self.finish_succeeded(&mut final_snapshot, output).await?;
937 }
938 } else {
939 self.finish_succeeded(&mut final_snapshot, output).await?;
940 }
941 }
942 Ok(Err(error)) if error.code == WorkflowFailureCode::Cancelled => {
943 self.finish_cancelled(&mut final_snapshot).await?;
944 }
945 Ok(Err(error)) if error.code == WorkflowFailureCode::Suspended => {
946 self.finish_suspended(&mut final_snapshot, error.message)
947 .await?;
948 }
949 Ok(Err(error)) => self.finish_failed(&mut final_snapshot, error).await?,
950 Err(_) => {
951 cancellation.cancel();
952 if let Some(durable) = self.load_committed_snapshot(&run_id).await? {
958 *final_snapshot = durable;
959 }
960 let step_failure = failure(
961 WorkflowFailureCode::BudgetExceeded,
962 "workflow wall-time budget exceeded",
963 false,
964 );
965 self.fail_timeout_frontier(
966 &mut final_snapshot,
967 &compiled.definition.plan,
968 step_failure.clone(),
969 )
970 .await?;
971 self.fail_active_steps(&mut final_snapshot, step_failure.clone())
972 .await?;
973 self.finish_failed(&mut final_snapshot, step_failure)
974 .await?;
975 }
976 }
977 Ok(final_snapshot.clone())
978 }
979
980 pub async fn progress(
981 &self,
982 run_id: &str,
983 since: u64,
984 ) -> Result<WorkflowProgress, WorkflowRunError> {
985 let snapshot = self
986 .repository
987 .load(run_id)
988 .await
989 .map_err(storage)?
990 .ok_or(WorkflowRunError::NotFound)?;
991 let events = self
992 .repository
993 .events_since(run_id, since)
994 .await
995 .map_err(storage)?;
996 Ok(WorkflowProgress { snapshot, events })
997 }
998
999 pub async fn list_run_ids(&self) -> Result<Vec<String>, WorkflowRunError> {
1000 self.repository.list_run_ids().await.map_err(storage)
1001 }
1002
1003 pub fn is_run_active(&self, run_id: &str) -> bool {
1010 self.active.contains_key(run_id)
1011 }
1012
1013 pub fn is_session_active(&self, session_id: &str) -> bool {
1016 self.active.iter().any(|run| run.session_id == session_id)
1017 }
1018
1019 #[cfg(test)]
1020 pub(super) fn test_active_ledger(
1021 &self,
1022 run_id: &str,
1023 ) -> (Arc<Mutex<WorkflowBudgetUsage>>, CancellationToken) {
1024 let active = self.active.get(run_id).unwrap();
1025 (active.ledger.clone(), active.cancellation.clone())
1026 }
1027
1028 pub async fn cancel(
1029 self: &Arc<Self>,
1030 run_id: &str,
1031 ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
1032 let snapshot = self
1033 .load_committed_snapshot(run_id)
1034 .await?
1035 .ok_or(WorkflowRunError::NotFound)?;
1036 if snapshot.status == WorkflowRunStatus::Cancelled {
1037 return Ok(snapshot);
1038 }
1039 if snapshot.status.is_terminal() {
1040 return Err(WorkflowRunError::Terminal);
1041 }
1042 let (active, _registration) = match self.active.entry(run_id.to_string()) {
1046 Entry::Occupied(entry) => (entry.get().clone(), None),
1047 Entry::Vacant(entry) => {
1048 let active = Arc::new(ActiveRun {
1049 session_id: snapshot.session_id.clone(),
1050 cancellation: CancellationToken::new(),
1051 snapshot: Arc::new(Mutex::new(snapshot)),
1052 root_run_id: run_id.to_string(),
1053 ledger: Arc::new(Mutex::new(WorkflowBudgetUsage::default())),
1054 drain_lock: Arc::new(Mutex::new(())),
1055 cleanup_pending: AtomicBool::new(false),
1056 has_agents: false,
1057 });
1058 entry.insert(active.clone());
1059 (
1060 active,
1061 Some(RuntimeRegistration {
1062 engine: Arc::downgrade(self),
1063 run_id: run_id.to_string(),
1064 }),
1065 )
1066 }
1067 };
1068 {
1069 active.cancellation.cancel();
1070 if !active.has_agents {
1072 let mut shared = active.snapshot.lock().await;
1073 if !shared.status.is_terminal() {
1074 self.finish_cancelled(&mut shared).await?;
1075 }
1076 return Ok(shared.clone());
1077 }
1078 let _drain_guard = active.drain_lock.lock().await;
1079 {
1080 let snapshot = active.snapshot.lock().await;
1081 if snapshot.status.is_terminal() {
1082 return Ok(snapshot.clone());
1083 }
1084 }
1085 let mut usage_unavailable = false;
1086 let mut ledger = active.ledger.lock().await;
1087 let (drained, drain_error) = self.agents.drain_cancelled(&active.root_run_id).await;
1088 let usage = {
1089 let usage = &mut *ledger;
1090 for result in drained {
1091 match result {
1092 Ok(result) => {
1093 usage.tokens = usage.tokens.saturating_add(result.tokens);
1094 usage.cost_micros = usage
1095 .cost_micros
1096 .zip(result.cost_micros)
1097 .map(|(a, b)| a.saturating_add(b));
1098 }
1099 Err(_) => {
1100 usage_unavailable = true;
1101 usage.cost_micros = None;
1102 }
1103 }
1104 }
1105 usage.clone()
1106 };
1107 drop(ledger);
1108 {
1109 let mut shared = active.snapshot.lock().await;
1110 self.transition(
1111 &mut shared,
1112 None,
1113 WorkflowRunEventKind::Phase {
1114 name: "AgentDrained".into(),
1115 },
1116 |snapshot| {
1117 snapshot.usage.tokens = usage.tokens;
1118 snapshot.usage.cost_micros = usage.cost_micros;
1119 },
1120 )
1121 .await?;
1122 }
1123 if drain_error.is_some() {
1124 active.cleanup_pending.store(true, Ordering::Release);
1125 return Err(WorkflowRunError::Preflight(
1126 "Workflow children have not confirmed stop; cancellation can be retried".into(),
1127 ));
1128 }
1129 let mut shared = active.snapshot.lock().await;
1130 if !shared.status.is_terminal() {
1131 if usage_unavailable {
1132 self.finish_failed(&mut shared, failure(WorkflowFailureCode::ExecutionFailed,
1133 "workflow Agent stopped but cumulative token observation unavailable; usage is not measured", false)).await?;
1134 } else {
1135 self.finish_cancelled(&mut shared).await?;
1136 }
1137 }
1138 let result = shared.clone();
1139 if active.cleanup_pending.swap(false, Ordering::AcqRel) {
1140 self.active.remove(run_id);
1141 self.events.remove(run_id);
1142 }
1143 Ok(result)
1144 }
1145 }
1146
1147 pub async fn recover(&self) -> Result<Vec<WorkflowRunSnapshot>, WorkflowRunError> {
1148 let mut recovered = Vec::new();
1149 for run_id in self.repository.list_run_ids().await.map_err(storage)? {
1150 let Some(mut snapshot) = self.repository.load(&run_id).await.map_err(storage)? else {
1151 continue;
1152 };
1153 if matches!(
1154 snapshot.status,
1155 WorkflowRunStatus::Queued | WorkflowRunStatus::Running
1156 ) {
1157 let reason = "process restarted; explicit safe restart is required".to_string();
1158 let active_steps = snapshot
1159 .steps
1160 .iter()
1161 .filter(|(_, step)| {
1162 matches!(
1163 step.status,
1164 WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1165 )
1166 })
1167 .map(|(id, _)| id.clone())
1168 .collect::<Vec<_>>();
1169 for step_id in active_steps {
1170 let state_id = step_id.clone();
1171 let step_reason = reason.clone();
1172 self.transition(
1173 &mut snapshot,
1174 Some(step_id),
1175 WorkflowRunEventKind::StepSuspended {
1176 reason: reason.clone(),
1177 },
1178 move |snapshot| {
1179 if let Some(step) = snapshot.steps.get_mut(&state_id) {
1180 step.status = WorkflowStepStatus::Suspended;
1181 step.failure = Some(failure(
1182 WorkflowFailureCode::RecoverySuspended,
1183 step_reason,
1184 true,
1185 ));
1186 }
1187 },
1188 )
1189 .await?;
1190 }
1191 self.transition(
1192 &mut snapshot,
1193 None,
1194 WorkflowRunEventKind::RunSuspended {
1195 reason: reason.clone(),
1196 },
1197 move |snapshot| {
1198 snapshot.status = WorkflowRunStatus::Suspended;
1199 snapshot.suspension = Some(WorkflowSuspensionContext::Recovery {
1200 reason: reason.clone(),
1201 });
1202 },
1203 )
1204 .await?;
1205 recovered.push(snapshot);
1206 }
1207 }
1208 Ok(recovered)
1209 }
1210
1211 pub fn subscribe(&self, run_id: &str) -> Option<broadcast::Receiver<WorkflowRunEvent>> {
1212 self.events.get(run_id).map(|sender| sender.subscribe())
1213 }
1214
1215 #[cfg(test)]
1216 pub(crate) fn runtime_resource_counts(&self) -> (usize, usize) {
1217 (self.active.len(), self.events.len())
1218 }
1219
1220 async fn pin_and_validate_bundle(
1221 &self,
1222 root: &WorkflowRunDefinition,
1223 ) -> Result<WorkflowDefinitionBundle, WorkflowRunError> {
1224 let bundle =
1225 self.definitions.pin_bundle(root).await.map_err(|_| {
1226 WorkflowRunError::Preflight("workflow bundle pin failed".to_string())
1227 })?;
1228 self.validate_bundle(root, &bundle)?;
1229 Ok(bundle)
1230 }
1231
1232 fn validate_bundle(
1233 &self,
1234 root: &WorkflowRunDefinition,
1235 bundle: &WorkflowDefinitionBundle,
1236 ) -> Result<(), WorkflowRunError> {
1237 if bundle.root_id != root.id
1238 || bundle.root_revision != root.revision
1239 || bundle.root() != Some(root)
1240 {
1241 return Err(WorkflowRunError::Preflight(
1242 "pinned bundle root identity/content mismatch".to_string(),
1243 ));
1244 }
1245 let serialized = serde_json::to_value(bundle).map_err(|_| {
1246 WorkflowRunError::Preflight("workflow bundle is not serializable".into())
1247 })?;
1248 reject_secret_material_in_definition(&serialized).map_err(WorkflowRunError::Preflight)?;
1249 let mut stack = vec![(root.id.clone(), root.revision, Vec::<String>::new())];
1250 let mut visited = BTreeSet::new();
1251 while let Some((id, revision, path)) = stack.pop() {
1252 let key = WorkflowDefinitionBundle::key(&id, revision);
1253 if path.contains(&key) {
1254 return Err(WorkflowRunError::Preflight(format!(
1255 "nested workflow cycle includes {key}"
1256 )));
1257 }
1258 if !visited.insert(key.clone()) {
1259 continue;
1260 }
1261 let definition = bundle.get(&id, revision).ok_or_else(|| {
1262 WorkflowRunError::Preflight(format!("pinned bundle is missing {key}"))
1263 })?;
1264 if definition.id != id || definition.revision != revision {
1265 return Err(WorkflowRunError::Preflight(
1266 "pinned bundle definition identity mismatch".to_string(),
1267 ));
1268 }
1269 let compiled = CompiledWorkflow::compile(definition.clone())?;
1270 let mut nested_path = path;
1271 nested_path.push(key);
1272 for step in compiled.steps.values() {
1273 if let WorkflowStepKind::Workflow {
1274 workflow_id,
1275 revision,
1276 args,
1277 } = &step.kind
1278 {
1279 let nested = bundle.get(workflow_id, *revision).ok_or_else(|| {
1280 WorkflowRunError::Preflight(format!(
1281 "pinned bundle is missing {workflow_id}@{revision}"
1282 ))
1283 })?;
1284 validate_nested_input_contract(args, &nested.input_schema, &compiled)
1285 .map_err(WorkflowRunError::Preflight)?;
1286 stack.push((workflow_id.clone(), *revision, nested_path.clone()));
1287 }
1288 }
1289 }
1290 Ok(())
1291 }
1292
1293 async fn preflight_bundle(
1294 &self,
1295 bundle: &WorkflowDefinitionBundle,
1296 session_id: &str,
1297 allowed: &BTreeSet<String>,
1298 trusted: bool,
1299 root_limits: &WorkflowBudgets,
1300 ) -> Result<HashMap<String, NamedAgentSpec>, WorkflowRunError> {
1301 let mut pinned_agents = HashMap::<String, NamedAgentSpec>::new();
1302 let mut stack = vec![(bundle.root_id.clone(), bundle.root_revision, 0_u32)];
1303 let mut visited = BTreeSet::new();
1304 while let Some((id, revision, depth)) = stack.pop() {
1305 if depth >= root_limits.max_nesting_depth {
1306 return Err(WorkflowRunError::Preflight(
1307 "nested workflow depth exceeded shared root limit".to_string(),
1308 ));
1309 }
1310 if !visited.insert(WorkflowDefinitionBundle::key(&id, revision)) {
1311 continue;
1312 }
1313 let definition = bundle.get(&id, revision).ok_or_else(|| {
1314 WorkflowRunError::Preflight("pinned workflow definition missing".to_string())
1315 })?;
1316 enforce_budget_within(&definition.budgets, root_limits).map_err(|message| {
1317 WorkflowRunError::Preflight(format!(
1318 "nested workflow budget expands root: {message}"
1319 ))
1320 })?;
1321 let compiled = CompiledWorkflow::compile(definition.clone())?;
1322 for step in compiled.steps.values() {
1323 let (target, capabilities) = match &step.kind {
1324 WorkflowStepKind::Tool {
1325 tool, capabilities, ..
1326 } => (WorkflowPolicyTarget::Tool(tool.clone()), capabilities),
1327 WorkflowStepKind::Agent {
1328 agent,
1329 capabilities,
1330 ..
1331 } => {
1332 let spec = if let Some(spec) = pinned_agents.get(agent) {
1333 spec.clone()
1334 } else {
1335 let spec = self
1336 .agents
1337 .resolve(agent, session_id)
1338 .await
1339 .map_err(|_| {
1340 WorkflowRunError::Preflight(
1341 "named agent resolution failed".to_string(),
1342 )
1343 })?
1344 .ok_or_else(|| {
1345 WorkflowRunError::Preflight(format!(
1346 "unknown named agent '{agent}'"
1347 ))
1348 })?;
1349 if spec.name != *agent {
1350 return Err(WorkflowRunError::Preflight(
1351 "named agent resolver returned mismatched identity".to_string(),
1352 ));
1353 }
1354 pinned_agents.insert(agent.clone(), spec.clone());
1355 spec
1356 };
1357 if (root_limits.max_cost_micros.is_some()
1358 || compiled.definition.budgets.max_cost_micros.is_some())
1359 && !spec.cost_supported
1360 {
1361 return Err(WorkflowRunError::UnsupportedMonetaryBudget);
1362 }
1363 if !capabilities
1364 .iter()
1365 .all(|capability| spec.allowed_capabilities.contains(capability))
1366 {
1367 return Err(WorkflowRunError::Preflight(format!(
1368 "agent '{agent}' capability expansion denied"
1369 )));
1370 }
1371 (WorkflowPolicyTarget::Agent(agent.clone()), capabilities)
1372 }
1373 WorkflowStepKind::Workflow {
1374 workflow_id,
1375 revision,
1376 args,
1377 } => {
1378 let nested = bundle.get(workflow_id, *revision).ok_or_else(|| {
1379 WorkflowRunError::Preflight(format!(
1380 "missing pinned workflow {workflow_id}@{revision}"
1381 ))
1382 })?;
1383 validate_nested_input_contract(args, &nested.input_schema, &compiled)
1384 .map_err(WorkflowRunError::Preflight)?;
1385 stack.push((workflow_id.clone(), *revision, depth + 1));
1386 (
1387 WorkflowPolicyTarget::Workflow {
1388 id: workflow_id.clone(),
1389 revision: *revision,
1390 },
1391 &Vec::new(),
1392 )
1393 }
1394 };
1395 let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
1396 if !requested.is_subset(allowed) {
1397 return Err(WorkflowRunError::Preflight(format!(
1398 "step '{}' exceeds root capabilities",
1399 step.id
1400 )));
1401 }
1402 if let PermissionDecision::Deny(_reason) = self
1403 .policy
1404 .authorize(session_id, &target, &requested, trusted)
1405 .await
1406 {
1407 return Err(WorkflowRunError::Preflight(
1408 "workflow policy denied this step".to_string(),
1409 ));
1410 }
1411 }
1412 }
1413 Ok(pinned_agents)
1414 }
1415
1416 fn enforce_ceilings(&self, budget: &WorkflowBudgets) -> Result<(), WorkflowRunError> {
1417 if budget.max_concurrency > self.ceilings.max_concurrency
1418 || budget.max_agents > self.ceilings.max_agents
1419 || budget.max_steps > self.ceilings.max_steps
1420 || budget.max_retries > self.ceilings.max_retries
1421 || budget.max_nesting_depth > self.ceilings.max_nesting_depth
1422 || budget.wall_time_ms > self.ceilings.wall_time_ms
1423 || exceeds_optional(budget.max_tokens, self.ceilings.max_tokens)
1424 || exceeds_optional(budget.max_cost_micros, self.ceilings.max_cost_micros)
1425 {
1426 return Err(WorkflowRunError::Preflight(
1427 "definition exceeds server workflow budget ceilings".to_string(),
1428 ));
1429 }
1430 Ok(())
1431 }
1432
1433 fn commit_fence(&self, run_id: &str) -> Arc<Mutex<()>> {
1434 self.commit_fences
1437 .retain(|_, fence| fence.strong_count() > 0);
1438 let mut entry = self.commit_fences.entry(run_id.to_owned()).or_default();
1439 if let Some(fence) = entry.upgrade() {
1440 return fence;
1441 }
1442 let fence = Arc::new(Mutex::new(()));
1443 *entry = Arc::downgrade(&fence);
1444 fence
1445 }
1446
1447 async fn load_committed_snapshot(
1448 &self,
1449 run_id: &str,
1450 ) -> Result<Option<WorkflowRunSnapshot>, WorkflowRunError> {
1451 let _commit_guard = self.commit_fence(run_id).lock_owned().await;
1452 self.repository.load(run_id).await.map_err(storage)
1453 }
1454
1455 async fn transition(
1456 &self,
1457 snapshot: &mut WorkflowRunSnapshot,
1458 step_id: Option<String>,
1459 kind: WorkflowRunEventKind,
1460 mutate: impl FnOnce(&mut WorkflowRunSnapshot),
1461 ) -> Result<(), WorkflowRunError> {
1462 let commit_guard = self.commit_fence(&snapshot.run_id).lock_owned().await;
1466 let mut candidate = self
1468 .repository
1469 .load(&snapshot.run_id)
1470 .await
1471 .map_err(storage)?
1472 .ok_or_else(|| WorkflowRunError::Storage("workflow snapshot missing".to_string()))?;
1473 mutate(&mut candidate);
1474 candidate.last_sequence += 1;
1475 candidate.updated_at = Utc::now();
1476 let event = event(&candidate, step_id, kind);
1477 let repository = self.repository.clone();
1478 let durable_candidate = candidate.clone();
1479 let durable_event = event.clone();
1480 let commit = tokio::spawn(async move {
1481 let _commit_guard = commit_guard;
1482 repository.commit(&durable_candidate, &durable_event).await
1483 });
1484 commit
1485 .await
1486 .map_err(|error| {
1487 WorkflowRunError::Storage(format!("workflow commit task failed: {error}"))
1488 })?
1489 .map_err(storage)?;
1490 *snapshot = candidate;
1491 self.publish(&event);
1492 Ok(())
1493 }
1494
1495 fn publish(&self, event: &WorkflowRunEvent) {
1496 if let Some(sender) = self.events.get(&event.run_id) {
1497 let _ = sender.send(event.clone());
1498 }
1499 }
1500
1501 async fn finish_succeeded(
1502 &self,
1503 snapshot: &mut WorkflowRunSnapshot,
1504 output: Value,
1505 ) -> Result<(), WorkflowRunError> {
1506 if snapshot.status.is_terminal() {
1507 return Ok(());
1508 }
1509 let copy = output.clone();
1510 self.transition(
1511 snapshot,
1512 None,
1513 WorkflowRunEventKind::RunSucceeded { output },
1514 move |snapshot| {
1515 snapshot.status = WorkflowRunStatus::Succeeded;
1516 snapshot.output = Some(copy);
1517 },
1518 )
1519 .await
1520 }
1521 async fn finish_failed(
1522 &self,
1523 snapshot: &mut WorkflowRunSnapshot,
1524 error: WorkflowFailure,
1525 ) -> Result<(), WorkflowRunError> {
1526 if snapshot.status.is_terminal() {
1527 return Ok(());
1528 }
1529 let copy = error.clone();
1530 self.transition(
1531 snapshot,
1532 None,
1533 WorkflowRunEventKind::RunFailed { failure: error },
1534 move |snapshot| {
1535 snapshot.status = WorkflowRunStatus::Failed;
1536 snapshot.failure = Some(copy);
1537 },
1538 )
1539 .await
1540 }
1541 async fn finish_cancelled(
1542 &self,
1543 snapshot: &mut WorkflowRunSnapshot,
1544 ) -> Result<(), WorkflowRunError> {
1545 if snapshot.status == WorkflowRunStatus::Cancelled {
1546 return Ok(());
1547 }
1548 let active_steps = snapshot
1549 .steps
1550 .iter()
1551 .filter(|(_, step)| {
1552 matches!(
1553 step.status,
1554 WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1555 )
1556 })
1557 .map(|(id, _)| id.clone())
1558 .collect::<Vec<_>>();
1559 for step_id in active_steps {
1560 let state_id = step_id.clone();
1561 self.transition(
1562 snapshot,
1563 Some(step_id),
1564 WorkflowRunEventKind::StepCancelled,
1565 move |snapshot| {
1566 if let Some(step) = snapshot.steps.get_mut(&state_id) {
1567 step.status = WorkflowStepStatus::Cancelled;
1568 step.failure = Some(failure(
1569 WorkflowFailureCode::Cancelled,
1570 "workflow cancelled",
1571 false,
1572 ));
1573 }
1574 },
1575 )
1576 .await?;
1577 }
1578 self.transition(
1579 snapshot,
1580 None,
1581 WorkflowRunEventKind::RunCancelled,
1582 |snapshot| {
1583 snapshot.status = WorkflowRunStatus::Cancelled;
1584 snapshot.failure = Some(failure(
1585 WorkflowFailureCode::Cancelled,
1586 "workflow cancelled",
1587 false,
1588 ));
1589 },
1590 )
1591 .await
1592 }
1593
1594 async fn finish_suspended(
1595 &self,
1596 snapshot: &mut WorkflowRunSnapshot,
1597 reason: String,
1598 ) -> Result<(), WorkflowRunError> {
1599 if snapshot.status.is_terminal() {
1600 return Ok(());
1601 }
1602 self.transition(
1603 snapshot,
1604 None,
1605 WorkflowRunEventKind::RunSuspended { reason },
1606 |snapshot| {
1607 snapshot.status = WorkflowRunStatus::Suspended;
1608 },
1609 )
1610 .await
1611 }
1612
1613 async fn fail_active_steps(
1614 &self,
1615 snapshot: &mut WorkflowRunSnapshot,
1616 error: WorkflowFailure,
1617 ) -> Result<(), WorkflowRunError> {
1618 let active_steps = snapshot
1619 .steps
1620 .iter()
1621 .filter(|(_, step)| {
1622 matches!(
1623 step.status,
1624 WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1625 )
1626 })
1627 .map(|(id, _)| id.clone())
1628 .collect::<Vec<_>>();
1629 for step_id in active_steps {
1630 let state_id = step_id.clone();
1631 let copy = error.clone();
1632 self.transition(
1633 snapshot,
1634 Some(step_id),
1635 WorkflowRunEventKind::StepFailed {
1636 failure: error.clone(),
1637 },
1638 move |snapshot| {
1639 if let Some(step) = snapshot.steps.get_mut(&state_id) {
1640 step.status = WorkflowStepStatus::Failed;
1641 step.failure = Some(copy);
1642 }
1643 },
1644 )
1645 .await?;
1646 }
1647 Ok(())
1648 }
1649
1650 async fn fail_timeout_frontier(
1651 &self,
1652 snapshot: &mut WorkflowRunSnapshot,
1653 plan: &WorkflowPlan,
1654 error: WorkflowFailure,
1655 ) -> Result<(), WorkflowRunError> {
1656 if snapshot.steps.values().any(|step| {
1657 matches!(
1658 step.status,
1659 WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1660 )
1661 }) {
1662 return Ok(());
1663 }
1664 for step_id in plan_frontier(plan) {
1665 if snapshot.steps.contains_key(&step_id) {
1666 continue;
1667 }
1668 let state_id = step_id.clone();
1669 let state_error = error.clone();
1670 let topology = plan.step_topology(&step_id, Vec::new());
1671 self.transition(
1672 snapshot,
1673 Some(step_id),
1674 WorkflowRunEventKind::StepFailed {
1675 failure: error.clone(),
1676 },
1677 move |snapshot| {
1678 snapshot.steps.insert(
1679 state_id.clone(),
1680 WorkflowStepSnapshot {
1681 id: state_id,
1682 topology,
1683 status: WorkflowStepStatus::Failed,
1684 input_hash: String::new(),
1685 output: None,
1686 failure: Some(state_error),
1687 attempts: 0,
1688 },
1689 );
1690 },
1691 )
1692 .await?;
1693 }
1694 Ok(())
1695 }
1696}
1697
1698impl RunContext {
1699 async fn execute_root(&self, completed_prefix: usize) -> Result<Value, WorkflowFailure> {
1700 if completed_prefix == 0 {
1701 return self
1702 .execute_node(&self.compiled.definition.plan, "root")
1703 .await;
1704 }
1705 let WorkflowPlan::Sequence { nodes } = &self.compiled.definition.plan else {
1706 unreachable!("validated flat sequence");
1707 };
1708 let WorkflowPlan::Step { step } = &nodes[completed_prefix - 1] else {
1709 unreachable!("validated tool leaf");
1710 };
1711 let mut result = self.snapshot.lock().await.steps[step]
1712 .output
1713 .clone()
1714 .expect("validated completed output");
1715 for (index, node) in nodes.iter().enumerate().skip(completed_prefix) {
1716 result = self.execute_node(node, &format!("root.{index}")).await?;
1717 }
1718 Ok(result)
1719 }
1720
1721 fn execute_node<'a>(&'a self, plan: &'a WorkflowPlan, path: &'a str) -> NodeFuture<'a> {
1722 Box::pin(async move {
1723 self.check_cancelled()?;
1724 match plan {
1725 WorkflowPlan::Step { step } => self.execute_step(step, path).await,
1726 WorkflowPlan::Sequence { nodes } => {
1727 let mut result = Value::Null;
1728 for (index, node) in nodes.iter().enumerate() {
1729 match self.execute_node(node, &format!("{path}.{index}")).await {
1730 Ok(value) => result = value,
1731 Err(error) => {
1732 if error.code == WorkflowFailureCode::DependencySkipped {
1733 for remaining in &nodes[index + 1..] {
1734 self.skip_plan(
1735 remaining,
1736 "dependency requested skip_dependents",
1737 )
1738 .await?;
1739 }
1740 }
1741 return Err(error);
1742 }
1743 }
1744 }
1745 Ok(result)
1746 }
1747 WorkflowPlan::Parallel { nodes } => {
1748 let parallel_cancellation = self.branch_cancellation.child_token();
1749 let mut futures = FuturesUnordered::new();
1750 for (index, node) in nodes.iter().enumerate() {
1751 let mut child = self.clone();
1752 child.branch_cancellation = parallel_cancellation.clone();
1753 futures.push(async move {
1754 (
1755 index,
1756 child.execute_node(node, &format!("{path}.{index}")).await,
1757 )
1758 });
1759 }
1760 let mut output = vec![Value::Null; nodes.len()];
1761 while let Some((index, result)) = futures.next().await {
1762 match result {
1763 Ok(value) => output[index] = value,
1764 Err(mut error) => {
1765 parallel_cancellation.cancel();
1766 drop(futures);
1772 if !self.pinned_agents.is_empty() {
1773 let _drain_guard = self.drain_lock.lock().await;
1774 let (usage_unavailable, drain_error) =
1775 self.drain_agents().await;
1776 self.checkpoint_agent_usage("AgentDrained").await?;
1777 if usage_unavailable {
1778 return Err(failure(WorkflowFailureCode::ExecutionFailed, "workflow Agent cumulative token observation unavailable; usage is not measured", false));
1779 }
1780 if drain_error.is_some() {
1781 return Err(failure(
1782 WorkflowFailureCode::ExecutionFailed,
1783 "Workflow children have not confirmed stop",
1784 false,
1785 ));
1786 }
1787 }
1788 self.cancel_active_parallel_steps(nodes).await?;
1789 error.message =
1790 format!("parallel branch[{index}] failed: {}", error.message);
1791 return Err(error);
1792 }
1793 }
1794 }
1795 Ok(Value::Array(output))
1796 }
1797 WorkflowPlan::Choice {
1798 condition,
1799 then_branch,
1800 else_branch,
1801 } => {
1802 let condition = self.resolve_ref(condition).await?;
1803 let selected = condition.as_bool().ok_or_else(|| {
1804 failure(
1805 WorkflowFailureCode::InvalidInput,
1806 "choice condition must be a boolean",
1807 false,
1808 )
1809 })?;
1810 let (chosen, unchosen, branch) = if selected {
1811 (then_branch, else_branch, "then")
1812 } else {
1813 (else_branch, then_branch, "else")
1814 };
1815 self.skip_plan(unchosen, "conditional branch not selected")
1816 .await?;
1817 self.execute_node(chosen, &format!("{path}.{branch}")).await
1818 }
1819 WorkflowPlan::Map { source, item, body } => {
1820 let source = self.resolve_ref(source).await?;
1821 let values = source.as_array().ok_or_else(|| {
1822 failure(
1823 WorkflowFailureCode::InvalidInput,
1824 "map source must be an array",
1825 false,
1826 )
1827 })?;
1828 let used = self.ledger.lock().await.steps as usize;
1829 let remaining = (self.root_limits.max_steps as usize).saturating_sub(used);
1830 let per_item = plan_leaf_count(body).max(1);
1831 if values
1832 .len()
1833 .checked_mul(per_item)
1834 .is_none_or(|required| required > remaining)
1835 {
1836 return Err(failure(
1837 WorkflowFailureCode::BudgetExceeded,
1838 "map cardinality exceeds remaining workflow step budget",
1839 false,
1840 ));
1841 }
1842 let futures = values.iter().cloned().enumerate().map(|(index, value)| {
1843 let mut child = self.clone();
1844 child.items.insert(item.clone(), value);
1845 child.scope = format!("{}[{index}]", self.scope);
1850 child.map_index_path.push(index);
1851 async move { child.execute_node(body, &format!("{path}[{index}]")).await }
1852 });
1853 let results = join_all(futures).await;
1854 let mut values = Vec::with_capacity(results.len());
1855 let mut failures = Vec::new();
1856 for (index, result) in results.into_iter().enumerate() {
1857 match result {
1858 Ok(value) => values.push(value),
1859 Err(error) => failures.push((index, error)),
1860 }
1861 }
1862 if failures.is_empty() {
1863 Ok(Value::Array(values))
1864 } else {
1865 let retryable = failures.iter().any(|(_, error)| error.retryable);
1866 let first_code = failures[0].1.code;
1867 let code = if failures
1868 .iter()
1869 .any(|(_, error)| error.code == WorkflowFailureCode::DependencySkipped)
1870 {
1871 WorkflowFailureCode::DependencySkipped
1872 } else if failures.iter().all(|(_, error)| error.code == first_code) {
1873 first_code
1874 } else {
1875 WorkflowFailureCode::ExecutionFailed
1876 };
1877 let diagnostics = failures
1878 .into_iter()
1879 .map(|(index, error)| format!("item[{index}]: {}", error.message))
1880 .collect::<Vec<_>>()
1881 .join("; ");
1882 Err(failure(
1883 code,
1884 format!("map items failed: {diagnostics}"),
1885 retryable,
1886 ))
1887 }
1888 }
1889 WorkflowPlan::Retry {
1890 node,
1891 max_attempts,
1892 delay_ms,
1893 } => {
1894 let limit =
1895 (*max_attempts).min(self.compiled.definition.budgets.max_retries + 1);
1896 let mut last = None;
1897 for attempt in 0..limit {
1898 match self
1899 .execute_node(node, &format!("{path}.retry{attempt}"))
1900 .await
1901 {
1902 Ok(value) => return Ok(value),
1903 Err(error) if error.retryable => {
1904 last = Some(error);
1905 if attempt + 1 < limit {
1906 self.reserve_retry().await?;
1907 self.checkpoint_usage("retry_reserved").await?;
1908 tokio::select! {
1909 _ = self.cancellation.cancelled() => return Err(failure(WorkflowFailureCode::Cancelled, "workflow cancelled", false)),
1910 _ = self.branch_cancellation.cancelled() => return Err(failure(WorkflowFailureCode::Cancelled, "workflow branch cancelled", false)),
1911 _ = tokio::time::sleep(Duration::from_millis(*delay_ms)) => {}
1912 }
1913 } else {
1914 break;
1915 }
1916 }
1917 Err(error) => return Err(error),
1918 }
1919 }
1920 Err(failure(
1921 WorkflowFailureCode::RetryExhausted,
1922 last.map_or_else(|| "retry exhausted".to_string(), |error| error.message),
1923 false,
1924 ))
1925 }
1926 }
1927 })
1928 }
1929
1930 async fn execute_step(&self, step_id: &str, _path: &str) -> Result<Value, WorkflowFailure> {
1931 self.check_cancelled()?;
1932 let step = self.compiled.steps.get(step_id).cloned().ok_or_else(|| {
1933 failure(
1934 WorkflowFailureCode::UnknownReference,
1935 format!("unknown step {step_id}"),
1936 false,
1937 )
1938 })?;
1939 let instance_id = self.instance_id(step_id);
1940 let input = match &step.kind {
1941 WorkflowStepKind::Tool { args, .. } | WorkflowStepKind::Workflow { args, .. } => {
1942 self.resolve_template(args).await?
1943 }
1944 WorkflowStepKind::Agent { prompt, .. } => self.resolve_template(prompt).await?,
1945 };
1946 let input_hash = hex::encode(Sha256::digest(
1947 serde_json::to_vec(&input).unwrap_or_default(),
1948 ));
1949 self.reserve_step().await?;
1950 self.checkpoint_usage("step_reserved").await?;
1951 self.step_transition(&instance_id, WorkflowRunEventKind::StepQueued, |snapshot| {
1952 let state = snapshot
1953 .steps
1954 .entry(instance_id.clone())
1955 .or_insert_with(|| WorkflowStepSnapshot {
1956 id: instance_id.clone(),
1957 topology: self
1958 .compiled
1959 .definition
1960 .plan
1961 .step_topology(step_id, self.map_index_path.clone()),
1962 status: WorkflowStepStatus::Queued,
1963 input_hash: input_hash.clone(),
1964 output: None,
1965 failure: None,
1966 attempts: 0,
1967 });
1968 state.status = WorkflowStepStatus::Queued;
1969 state.input_hash = input_hash;
1970 state.output = None;
1971 state.failure = None;
1972 })
1973 .await?;
1974 let _permit = if matches!(&step.kind, WorkflowStepKind::Workflow { .. }) {
1975 None
1976 } else {
1977 Some(tokio::select! {
1978 _ = self.cancellation.cancelled() => {
1979 let cancelled_id = instance_id.clone();
1980 self.step_transition(&instance_id, WorkflowRunEventKind::StepCancelled, move |snapshot| {
1981 if let Some(state) = snapshot.steps.get_mut(&cancelled_id) { state.status = WorkflowStepStatus::Cancelled; }
1982 }).await?;
1983 return Err(failure(WorkflowFailureCode::Cancelled, "workflow cancelled", false));
1984 }
1985 _ = self.branch_cancellation.cancelled() => {
1986 let cancelled_id = instance_id.clone();
1987 self.step_transition(&instance_id, WorkflowRunEventKind::StepCancelled, move |snapshot| {
1988 if let Some(state) = snapshot.steps.get_mut(&cancelled_id) {
1989 state.status = WorkflowStepStatus::Cancelled;
1990 }
1991 }).await?;
1992 return Err(failure(WorkflowFailureCode::Cancelled, "workflow branch cancelled", false));
1993 }
1994 permit = self.semaphore.acquire() => permit.map_err(|_| failure(WorkflowFailureCode::ExecutionFailed, "workflow semaphore closed", false))?,
1995 })
1996 };
1997 let started_id = instance_id.clone();
1998 self.step_transition(
1999 &instance_id,
2000 WorkflowRunEventKind::StepStarted,
2001 move |snapshot| {
2002 if let Some(state) = snapshot.steps.get_mut(&started_id) {
2003 state.status = WorkflowStepStatus::Running;
2004 state.attempts += 1;
2005 }
2006 },
2007 )
2008 .await?;
2009 let result = self
2010 .dispatch(&step, input, &instance_id)
2011 .await
2012 .and_then(|output| {
2013 self.check_suspension_restriction()?;
2014 Ok(output)
2015 });
2016 let result = match result {
2017 Ok(output) => {
2018 if let Some(schema) = &step.output_schema {
2019 validate_schema(schema, &output)
2020 .map(|()| output)
2021 .map_err(|message| {
2022 failure(WorkflowFailureCode::InvalidOutput, message, false)
2023 })
2024 } else {
2025 Ok(output)
2026 }
2027 }
2028 Err(error) => Err(error),
2029 };
2030 let result = result.and_then(|output| {
2031 reject_secret_material(&output)
2032 .map(|()| output)
2033 .map_err(|message| failure(WorkflowFailureCode::InvalidOutput, message, false))
2034 });
2035 match result {
2036 Ok(output) => {
2037 let copy = output.clone();
2038 let completed_id = instance_id.clone();
2039 self.step_transition(
2040 &instance_id,
2041 WorkflowRunEventKind::StepCompleted {
2042 output: output.clone(),
2043 },
2044 move |snapshot| {
2045 if let Some(state) = snapshot.steps.get_mut(&completed_id) {
2046 state.status = WorkflowStepStatus::Succeeded;
2047 state.output = Some(copy);
2048 }
2049 },
2050 )
2051 .await?;
2052 Ok(output)
2053 }
2054 Err(error) => {
2055 if error.code == WorkflowFailureCode::Cancelled {
2056 let cancelled_id = instance_id.clone();
2057 self.step_transition(
2058 &instance_id,
2059 WorkflowRunEventKind::StepCancelled,
2060 move |snapshot| {
2061 if let Some(state) = snapshot.steps.get_mut(&cancelled_id) {
2062 state.status = WorkflowStepStatus::Cancelled;
2063 state.failure = Some(failure(
2064 WorkflowFailureCode::Cancelled,
2065 "workflow branch cancelled",
2066 false,
2067 ));
2068 }
2069 },
2070 )
2071 .await?;
2072 return Err(error);
2073 }
2074 if error.code == WorkflowFailureCode::Suspended {
2075 let reason = error.message.clone();
2076 let suspended_id = instance_id.clone();
2077 self.step_transition(
2078 &instance_id,
2079 WorkflowRunEventKind::StepSuspended {
2080 reason: reason.clone(),
2081 },
2082 move |snapshot| {
2083 if let Some(state) = snapshot.steps.get_mut(&suspended_id) {
2084 state.status = WorkflowStepStatus::Suspended;
2085 state.failure =
2086 Some(failure(WorkflowFailureCode::Suspended, reason, true));
2087 }
2088 },
2089 )
2090 .await?;
2091 return Err(error);
2092 }
2093 let copy = error.clone();
2094 let failed_id = instance_id.clone();
2095 self.step_transition(
2096 &instance_id,
2097 WorkflowRunEventKind::StepFailed {
2098 failure: error.clone(),
2099 },
2100 move |snapshot| {
2101 if let Some(state) = snapshot.steps.get_mut(&failed_id) {
2102 state.status = WorkflowStepStatus::Failed;
2103 state.failure = Some(copy);
2104 }
2105 },
2106 )
2107 .await?;
2108 self.check_suspension_restriction()?;
2111 match step.failure {
2112 FailurePolicy::ContinueWithError => Ok(serde_json::json!({"error": error})),
2113 FailurePolicy::SkipDependents => Err(failure(
2114 WorkflowFailureCode::DependencySkipped,
2115 format!("{} (dependents skipped)", error.message),
2116 false,
2117 )),
2118 FailurePolicy::FailFast => Err(error),
2119 }
2120 }
2121 }
2122 }
2123
2124 fn check_suspension_restriction(&self) -> Result<(), WorkflowFailure> {
2125 if bamboo_agent_core::tools::suspension_running_violated() {
2126 return Err(failure(
2127 WorkflowFailureCode::ExecutionFailed,
2128 "workflow cannot continue after a forbidden Running outcome; cleanup requested, stop unconfirmed",
2129 false,
2130 ));
2131 }
2132 Ok(())
2133 }
2134
2135 async fn dispatch(
2136 &self,
2137 step: &WorkflowStepDefinition,
2138 input: Value,
2139 instance_id: &str,
2140 ) -> Result<Value, WorkflowFailure> {
2141 self.check_suspension_restriction()?;
2142 let session_id = { self.snapshot.lock().await.session_id.clone() };
2143 match &step.kind {
2144 WorkflowStepKind::Tool {
2145 tool, capabilities, ..
2146 } => {
2147 self.authorize(
2148 &session_id,
2149 WorkflowPolicyTarget::Tool(tool.clone()),
2150 capabilities,
2151 )
2152 .await?;
2153 let (resolved_input, resolved_secrets) =
2154 self.resolve_secret_handles(&input, &session_id).await?;
2155 let arguments = serde_json::to_string(&resolved_input).map_err(|error| {
2156 failure(WorkflowFailureCode::InvalidInput, error.to_string(), false)
2157 })?;
2158 let call = ToolCall {
2159 id: format!("workflow-{}", Uuid::new_v4()),
2160 tool_type: "function".to_string(),
2161 function: FunctionCall {
2162 name: tool.clone(),
2163 arguments,
2164 },
2165 };
2166 let permission_port = self
2167 .engine
2168 .session_permissions
2169 .read()
2170 .unwrap_or_else(std::sync::PoisonError::into_inner)
2171 .clone();
2172 let session_flags = match permission_port {
2173 Some(port) => port.flags_for_session(&session_id).await.map_err(|error| {
2174 failure(
2175 WorkflowFailureCode::ExecutionFailed,
2176 format!("workflow session permission posture is unavailable: {error}"),
2177 true,
2178 )
2179 })?,
2180 None => ToolExecutionSessionFlags::default(),
2181 };
2182 let context = ToolExecutionContext {
2183 executing_supervisor: None,
2184 session_id: Some(&session_id),
2185 root_session_id: None,
2189 tool_call_id: &call.id,
2190 event_tx: None,
2191 available_tool_schemas: None,
2192 bypass_permissions: session_flags.bypass_permissions,
2193 auto_approve_permissions: session_flags.auto_approve_permissions,
2194 plan_read_only: session_flags.plan_read_only,
2195 can_async_resume: false,
2196 bash_completion_sink: None,
2197 pre_parsed_args: Some(&resolved_input),
2198 };
2199 if session_flags.plan_read_only
2200 && !bamboo_tools::orchestrator::plan_mode_allows_tool(&call.function.name)
2201 {
2202 return Err(failure(
2203 WorkflowFailureCode::ExecutionFailed,
2204 format!("Plan mode: {} operation blocked", call.function.name),
2205 false,
2206 ));
2207 }
2208 self.check_suspension_restriction()?;
2211 let outcome = self
2212 .engine
2213 .tools
2214 .execute_with_context_outcome(&call, context)
2215 .await
2216 .map(ToolOutcome::enforce_suspension_restriction)
2217 .map_err(|error| {
2218 let (code, message, retryable) = match error {
2219 bamboo_agent_core::tools::ToolError::NotFound(_) => (
2220 WorkflowFailureCode::UnknownReference,
2221 "workflow tool is not available",
2222 false,
2223 ),
2224 bamboo_agent_core::tools::ToolError::InvalidArguments(_) => (
2225 WorkflowFailureCode::InvalidInput,
2226 "workflow tool arguments were rejected",
2227 false,
2228 ),
2229 bamboo_agent_core::tools::ToolError::Execution(_) => (
2230 WorkflowFailureCode::ExecutionFailed,
2231 "workflow tool execution was denied or failed",
2232 true,
2233 ),
2234 };
2235 failure(code, message, retryable)
2236 })?;
2237 self.check_suspension_restriction()?;
2238 match outcome {
2239 ToolOutcome::Completed(result) => {
2240 let output = parse_tool_result(result)?;
2241 if contains_any_secret_material(&output, &resolved_secrets) {
2242 return Err(failure(
2243 WorkflowFailureCode::InvalidOutput,
2244 "workflow tool output contained resolved secret material",
2245 false,
2246 ));
2247 }
2248 Ok(output)
2249 }
2250 ToolOutcome::NeedsHuman { question, .. } => {
2251 self.persist_suspension(WorkflowSuspensionContext::ToolApproval {
2252 step_id: instance_id.to_string(),
2253 tool: tool.clone(),
2254 tool_call_id: question.tool_call_id,
2255 })
2256 .await?;
2257 Err(failure(
2258 WorkflowFailureCode::Suspended,
2259 "workflow tool requires human approval",
2260 true,
2261 ))
2262 }
2263 ToolOutcome::Running(handle) => {
2264 let tool_call_id = handle.tool_call_id.clone();
2265 (handle.kill)();
2266 self.persist_suspension(WorkflowSuspensionContext::ToolRunning {
2267 step_id: instance_id.to_string(),
2268 tool: tool.clone(),
2269 tool_call_id,
2270 killed: true,
2271 })
2272 .await?;
2273 Err(failure(
2274 WorkflowFailureCode::Suspended,
2275 "workflow tool is running without a durable workflow resume handle",
2276 true,
2277 ))
2278 }
2279 }
2280 }
2281 WorkflowStepKind::Agent {
2282 agent,
2283 model,
2284 effort,
2285 capabilities,
2286 structured_output_attempts,
2287 ..
2288 } => {
2289 if contains_secret_handle(&input) {
2290 return Err(failure(
2291 WorkflowFailureCode::PermissionDenied,
2292 "secret capability handles are supported only for tool arguments",
2293 false,
2294 ));
2295 }
2296 self.authorize(
2297 &session_id,
2298 WorkflowPolicyTarget::Agent(agent.clone()),
2299 capabilities,
2300 )
2301 .await?;
2302 let spec = self.pinned_agents.get(agent).cloned().ok_or_else(|| {
2303 failure(
2304 WorkflowFailureCode::PermissionDenied,
2305 "named agent was not pinned during preflight",
2306 false,
2307 )
2308 })?;
2309 let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
2310 if !requested.is_subset(&spec.allowed_capabilities) {
2311 return Err(failure(
2312 WorkflowFailureCode::PermissionDenied,
2313 "named agent capability intersection changed",
2314 false,
2315 ));
2316 }
2317 let mut last_error = None;
2318 for _ in 0..*structured_output_attempts {
2319 self.ensure_agent_usage_budget_available().await?;
2320 self.reserve_agent().await?;
2321 if !spec.cost_supported {
2322 self.ledger.lock().await.cost_micros = None;
2323 }
2324 self.checkpoint_usage("agent_reserved").await?;
2325 self.check_suspension_restriction()?;
2326 match self
2327 .engine
2328 .agents
2329 .execute(
2330 &spec,
2331 input.clone(),
2332 model.as_deref(),
2333 effort.as_deref(),
2334 &requested,
2335 &session_id,
2336 &self.root_run_id,
2337 self.branch_cancellation.child_token(),
2338 )
2339 .await
2340 {
2341 Ok(result) => {
2342 let _drain_guard = self.drain_lock.lock().await;
2343 let mut usage = self.ledger.lock().await;
2344 if result
2345 .attempt_id
2346 .as_ref()
2347 .is_some_and(|id| !self.engine.agents.acknowledge_result(id))
2348 {
2349 self.check_cancelled()?;
2350 return Err(failure(
2351 WorkflowFailureCode::Cancelled,
2352 "workflow Agent result already drained",
2353 false,
2354 ));
2355 }
2356 let exceeded =
2358 self.record_usage(&mut usage, result.tokens, result.cost_micros);
2359 drop(usage);
2360 self.checkpoint_usage("agent_usage_recorded").await?;
2361 self.check_cancelled()?;
2362 if let Some(error) = result.failure {
2363 return Err(error);
2364 }
2365 if let Some(error) = exceeded {
2366 return Err(error);
2367 }
2368 if let Some(schema) = &step.output_schema {
2369 if let Err(error) = validate_schema(schema, &result.output) {
2370 last_error = Some(error);
2371 continue;
2372 }
2373 }
2374 return Ok(result.output);
2375 }
2376 Err(_error) => {
2377 self.check_cancelled()?;
2378 return Err(failure(
2379 WorkflowFailureCode::ExecutionFailed,
2380 "named agent execution or usage observation unavailable",
2381 false,
2382 ));
2383 }
2384 }
2385 }
2386 Err(failure(
2387 WorkflowFailureCode::InvalidOutput,
2388 last_error.unwrap_or_else(|| "agent structured output exhausted".to_string()),
2389 false,
2390 ))
2391 }
2392 WorkflowStepKind::Workflow {
2393 workflow_id,
2394 revision,
2395 ..
2396 } => {
2397 if self.depth + 1 >= self.root_limits.max_nesting_depth {
2398 return Err(failure(
2399 WorkflowFailureCode::BudgetExceeded,
2400 "nested workflow depth exceeded",
2401 false,
2402 ));
2403 }
2404 let definition = self
2405 .bundle
2406 .get(workflow_id, *revision)
2407 .cloned()
2408 .ok_or_else(|| {
2409 failure(
2410 WorkflowFailureCode::UnknownReference,
2411 format!("persisted bundle missing workflow {workflow_id}@{revision}"),
2412 false,
2413 )
2414 })?;
2415 let nested = StartWorkflowRun {
2416 definition,
2417 args: input,
2418 session_id,
2419 workspace_trusted: self.workspace_trusted,
2420 allowed_capabilities: self.allowed_capabilities.iter().cloned().collect(),
2421 };
2422 let parent_run_id = self.snapshot.lock().await.run_id.clone();
2423 self.check_suspension_restriction()?;
2424 let result = Box::pin(self.engine.run_internal(
2425 nested,
2426 self.bundle.clone(),
2427 self.pinned_agents.clone(),
2428 Some(parent_run_id),
2429 Some(instance_id.to_string()),
2430 self.depth + 1,
2431 self.branch_cancellation.clone(),
2432 self.ledger.clone(),
2433 self.root_limits.clone(),
2434 self.semaphore.clone(),
2435 None,
2436 Some(self.root_run_id.clone()),
2437 ))
2438 .await
2439 .map_err(|_error| {
2440 failure(
2441 WorkflowFailureCode::ExecutionFailed,
2442 "nested workflow execution failed",
2443 false,
2444 )
2445 })?;
2446 result.output.ok_or_else(|| {
2447 result.failure.unwrap_or_else(|| {
2448 failure(
2449 WorkflowFailureCode::ExecutionFailed,
2450 "nested workflow returned no output",
2451 false,
2452 )
2453 })
2454 })
2455 }
2456 }
2457 }
2458
2459 async fn authorize(
2460 &self,
2461 session_id: &str,
2462 target: WorkflowPolicyTarget,
2463 capabilities: &[String],
2464 ) -> Result<(), WorkflowFailure> {
2465 let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
2466 if !requested.is_subset(&self.allowed_capabilities) {
2467 return Err(failure(
2468 WorkflowFailureCode::PermissionDenied,
2469 "step capability exceeds root policy",
2470 false,
2471 ));
2472 }
2473 match self
2474 .engine
2475 .policy
2476 .authorize(session_id, &target, &requested, self.workspace_trusted)
2477 .await
2478 {
2479 PermissionDecision::Allow => Ok(()),
2480 PermissionDecision::Deny(_reason) => Err(failure(
2481 if self.workspace_trusted {
2482 WorkflowFailureCode::PermissionDenied
2483 } else {
2484 WorkflowFailureCode::UntrustedWorkspace
2485 },
2486 "workflow policy denied this step",
2487 false,
2488 )),
2489 }
2490 }
2491
2492 async fn step_transition(
2493 &self,
2494 step_id: &str,
2495 kind: WorkflowRunEventKind,
2496 mutate: impl FnOnce(&mut WorkflowRunSnapshot),
2497 ) -> Result<(), WorkflowFailure> {
2498 let usage = self.ledger.lock().await.clone();
2499 let mut snapshot = self.snapshot.lock().await;
2500 snapshot.usage = usage;
2501 self.engine
2502 .transition(&mut snapshot, Some(step_id.to_string()), kind, mutate)
2503 .await
2504 .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2505 }
2506
2507 async fn reserve_step(&self) -> Result<(), WorkflowFailure> {
2508 let mut usage = self.ledger.lock().await;
2509 if usage.steps >= self.root_limits.max_steps {
2510 return Err(failure(
2511 WorkflowFailureCode::BudgetExceeded,
2512 "workflow step budget exceeded",
2513 false,
2514 ));
2515 }
2516 usage.steps += 1;
2517 Ok(())
2518 }
2519
2520 async fn reserve_retry(&self) -> Result<(), WorkflowFailure> {
2521 let mut usage = self.ledger.lock().await;
2522 if usage.retries >= self.root_limits.max_retries {
2523 return Err(failure(
2524 WorkflowFailureCode::BudgetExceeded,
2525 "workflow retry budget exceeded",
2526 false,
2527 ));
2528 }
2529 usage.retries += 1;
2530 Ok(())
2531 }
2532
2533 async fn reserve_agent(&self) -> Result<(), WorkflowFailure> {
2534 let mut usage = self.ledger.lock().await;
2535 if usage.agents >= self.root_limits.max_agents {
2536 return Err(failure(
2537 WorkflowFailureCode::BudgetExceeded,
2538 "workflow agent budget exceeded",
2539 false,
2540 ));
2541 }
2542 usage.agents += 1;
2543 Ok(())
2544 }
2545
2546 fn record_usage(
2547 &self,
2548 usage: &mut WorkflowBudgetUsage,
2549 tokens: u64,
2550 cost_micros: Option<u64>,
2551 ) -> Option<WorkflowFailure> {
2552 let next_tokens = usage.tokens.saturating_add(tokens);
2553 let next_cost = usage
2554 .cost_micros
2555 .zip(cost_micros)
2556 .map(|(a, b)| a.saturating_add(b));
2557 usage.tokens = next_tokens;
2558 usage.cost_micros = next_cost;
2559 if self
2560 .root_limits
2561 .max_tokens
2562 .is_some_and(|limit| next_tokens > limit)
2563 || self
2564 .root_limits
2565 .max_cost_micros
2566 .is_some_and(|limit| next_cost.is_none_or(|cost| cost > limit))
2567 {
2568 return Some(failure(
2569 WorkflowFailureCode::BudgetExceeded,
2570 "workflow token/cost budget exceeded",
2571 false,
2572 ));
2573 }
2574 None
2575 }
2576
2577 async fn drain_agents(&self) -> (bool, Option<String>) {
2578 let mut ledger = self.ledger.lock().await;
2581 let (results, error) = self.engine.agents.drain_cancelled(&self.root_run_id).await;
2582 let mut unavailable = false;
2583 for result in results {
2584 match result {
2585 Ok(result) => {
2586 self.record_usage(&mut ledger, result.tokens, result.cost_micros);
2587 }
2588 Err(_) => {
2589 unavailable = true;
2590 ledger.cost_micros = None;
2591 }
2592 }
2593 }
2594 (unavailable, error)
2595 }
2596
2597 async fn ensure_agent_usage_budget_available(&self) -> Result<(), WorkflowFailure> {
2598 let usage = self.ledger.lock().await;
2599 if self
2600 .root_limits
2601 .max_tokens
2602 .is_some_and(|limit| usage.tokens >= limit)
2603 || self
2604 .root_limits
2605 .max_cost_micros
2606 .is_some_and(|limit| usage.cost_micros.is_none_or(|cost| cost >= limit))
2607 {
2608 return Err(failure(
2609 WorkflowFailureCode::BudgetExceeded,
2610 "workflow token/cost budget exhausted before agent dispatch",
2611 false,
2612 ));
2613 }
2614 Ok(())
2615 }
2616
2617 async fn checkpoint_agent_usage(&self, name: &str) -> Result<(), WorkflowFailure> {
2618 let usage = self.ledger.lock().await.clone();
2619 let mut snapshot = self.snapshot.lock().await;
2620 self.engine
2621 .transition(
2622 &mut snapshot,
2623 None,
2624 WorkflowRunEventKind::Phase {
2625 name: name.to_string(),
2626 },
2627 move |snapshot| {
2628 snapshot.usage.tokens = usage.tokens;
2631 snapshot.usage.cost_micros = usage.cost_micros;
2632 },
2633 )
2634 .await
2635 .map_err(|e| failure(WorkflowFailureCode::Storage, e.to_string(), false))
2636 }
2637
2638 async fn checkpoint_usage(&self, name: &str) -> Result<(), WorkflowFailure> {
2639 let usage = self.ledger.lock().await.clone();
2640 let mut snapshot = self.snapshot.lock().await;
2641 self.engine
2642 .transition(
2643 &mut snapshot,
2644 None,
2645 WorkflowRunEventKind::Phase {
2646 name: name.to_string(),
2647 },
2648 move |snapshot| snapshot.usage = usage,
2649 )
2650 .await
2651 .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2652 }
2653
2654 async fn persist_suspension(
2655 &self,
2656 context: WorkflowSuspensionContext,
2657 ) -> Result<(), WorkflowFailure> {
2658 let mut snapshot = self.snapshot.lock().await;
2659 self.engine
2660 .transition(
2661 &mut snapshot,
2662 None,
2663 WorkflowRunEventKind::Phase {
2664 name: "suspension_context_persisted".to_string(),
2665 },
2666 move |snapshot| snapshot.suspension = Some(context),
2667 )
2668 .await
2669 .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2670 }
2671
2672 async fn cancel_active_parallel_steps(
2673 &self,
2674 nodes: &[WorkflowPlan],
2675 ) -> Result<(), WorkflowFailure> {
2676 let sibling_steps = nodes
2677 .iter()
2678 .flat_map(plan_step_ids)
2679 .collect::<BTreeSet<_>>();
2680 let active = {
2681 let snapshot = self.snapshot.lock().await;
2682 snapshot
2683 .steps
2684 .iter()
2685 .filter(|(_, step)| {
2686 matches!(
2687 step.status,
2688 WorkflowStepStatus::Queued | WorkflowStepStatus::Running
2689 ) && sibling_steps
2690 .iter()
2691 .any(|step_id| self.step_is_in_scope(step, step_id))
2692 })
2693 .map(|(id, _)| id.clone())
2694 .collect::<Vec<_>>()
2695 };
2696 for step_id in active {
2697 let state_id = step_id.clone();
2698 self.step_transition(
2699 &step_id,
2700 WorkflowRunEventKind::StepCancelled,
2701 move |snapshot| {
2702 if let Some(step) = snapshot.steps.get_mut(&state_id) {
2703 step.status = WorkflowStepStatus::Cancelled;
2704 step.failure = Some(failure(
2705 WorkflowFailureCode::Cancelled,
2706 "parallel sibling cancelled by fail_fast",
2707 false,
2708 ));
2709 }
2710 },
2711 )
2712 .await?;
2713 }
2714 Ok(())
2715 }
2716
2717 async fn resolve_secret_handles(
2718 &self,
2719 value: &Value,
2720 session_id: &str,
2721 ) -> Result<(Value, Vec<String>), WorkflowFailure> {
2722 fn walk<'a>(
2723 context: &'a RunContext,
2724 value: &'a Value,
2725 session_id: &'a str,
2726 ) -> SecretResolutionFuture<'a> {
2727 Box::pin(async move {
2728 match value {
2729 Value::Object(object) if object.contains_key("$secret") => {
2730 let handle: bamboo_domain::WorkflowSecretHandle =
2731 serde_json::from_value(value.clone()).map_err(|_| {
2732 failure(
2733 WorkflowFailureCode::InvalidInput,
2734 "malformed secret capability handle",
2735 false,
2736 )
2737 })?;
2738 let material = context
2739 .engine
2740 .secrets
2741 .resolve(session_id, &handle.capability)
2742 .await
2743 .map_err(|_| {
2744 failure(
2745 WorkflowFailureCode::PermissionDenied,
2746 "secret capability resolution denied",
2747 false,
2748 )
2749 })?;
2750 let material = material.into_exposed();
2751 Ok((Value::String(material.clone()), vec![material]))
2752 }
2753 Value::Object(object) => {
2754 let mut resolved = serde_json::Map::new();
2755 let mut secrets = Vec::new();
2756 for (key, child) in object {
2757 let (child, mut child_secrets) =
2758 walk(context, child, session_id).await?;
2759 resolved.insert(key.clone(), child);
2760 secrets.append(&mut child_secrets);
2761 }
2762 Ok((Value::Object(resolved), secrets))
2763 }
2764 Value::Array(array) => {
2765 let mut resolved = Vec::with_capacity(array.len());
2766 let mut secrets = Vec::new();
2767 for child in array {
2768 let (child, mut child_secrets) =
2769 walk(context, child, session_id).await?;
2770 resolved.push(child);
2771 secrets.append(&mut child_secrets);
2772 }
2773 Ok((Value::Array(resolved), secrets))
2774 }
2775 value => Ok((value.clone(), Vec::new())),
2776 }
2777 })
2778 }
2779 walk(self, value, session_id).await
2780 }
2781
2782 fn skip_plan<'a>(&'a self, plan: &'a WorkflowPlan, reason: &'a str) -> NodeFuture<'a> {
2783 Box::pin(async move {
2784 match plan {
2785 WorkflowPlan::Step { step } => {
2786 let instance_id = self.instance_id(step);
2787 let mut instances = {
2790 let snapshot = self.snapshot.lock().await;
2791 snapshot
2792 .steps
2793 .iter()
2794 .filter(|(_, state)| self.step_is_in_scope(state, step))
2795 .map(|(id, _)| id.clone())
2796 .collect::<Vec<_>>()
2797 };
2798 if instances.is_empty() {
2799 instances.push(instance_id);
2800 }
2801 for instance_id in instances {
2802 let reason_owned = reason.to_string();
2803 let state_id = instance_id.clone();
2804 self.step_transition(
2805 &instance_id,
2806 WorkflowRunEventKind::StepSkipped {
2807 reason: reason.to_string(),
2808 },
2809 move |snapshot| {
2810 let state = snapshot.steps.entry(state_id.clone()).or_insert(
2811 WorkflowStepSnapshot {
2812 id: state_id,
2813 topology: self
2814 .compiled
2815 .definition
2816 .plan
2817 .step_topology(step, self.map_index_path.clone()),
2818 status: WorkflowStepStatus::Skipped,
2819 input_hash: String::new(),
2820 output: None,
2821 failure: None,
2822 attempts: 0,
2823 },
2824 );
2825 state.status = WorkflowStepStatus::Skipped;
2826 state.output = None;
2827 state.failure = Some(failure(
2828 WorkflowFailureCode::DependencySkipped,
2829 reason_owned,
2830 false,
2831 ));
2832 },
2833 )
2834 .await?;
2835 }
2836 }
2837 WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
2838 for node in nodes {
2839 self.skip_plan(node, reason).await?;
2840 }
2841 }
2842 WorkflowPlan::Choice {
2843 then_branch,
2844 else_branch,
2845 ..
2846 } => {
2847 self.skip_plan(then_branch, reason).await?;
2848 self.skip_plan(else_branch, reason).await?;
2849 }
2850 WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
2851 self.skip_plan(body, reason).await?;
2852 }
2853 }
2854 Ok(Value::Null)
2855 })
2856 }
2857
2858 async fn resolve_template(&self, value: &Value) -> Result<Value, WorkflowFailure> {
2859 Box::pin(self.resolve_template_inner(value)).await
2860 }
2861
2862 fn resolve_template_inner<'a>(
2863 &'a self,
2864 value: &'a Value,
2865 ) -> Pin<Box<dyn Future<Output = Result<Value, WorkflowFailure>> + Send + 'a>> {
2866 Box::pin(async move {
2867 match value {
2868 Value::Object(object) if object.get("from").is_some() => {
2869 let reference: ValueRef =
2870 serde_json::from_value(value.clone()).map_err(|error| {
2871 failure(
2872 WorkflowFailureCode::InvalidInput,
2873 format!("malformed value reference: {error}"),
2874 false,
2875 )
2876 })?;
2877 self.resolve_ref(&reference).await
2878 }
2879 Value::Object(object) => {
2880 let mut resolved = serde_json::Map::new();
2881 for (key, child) in object {
2882 resolved.insert(key.clone(), self.resolve_template_inner(child).await?);
2883 }
2884 Ok(Value::Object(resolved))
2885 }
2886 Value::Array(array) => {
2887 let mut resolved = Vec::with_capacity(array.len());
2888 for child in array {
2889 resolved.push(self.resolve_template_inner(child).await?);
2890 }
2891 Ok(Value::Array(resolved))
2892 }
2893 value => Ok(value.clone()),
2894 }
2895 })
2896 }
2897
2898 async fn resolve_ref(&self, reference: &ValueRef) -> Result<Value, WorkflowFailure> {
2899 let (root, pointer) = match reference {
2900 ValueRef::Args { pointer } => (
2901 self.snapshot.lock().await.validated_args.clone(),
2902 pointer.as_str(),
2903 ),
2904 ValueRef::Step { step, pointer } => {
2905 let snapshot = self.snapshot.lock().await;
2906 let exact = self.instance_id(step);
2907 let output = snapshot
2908 .steps
2909 .get(&exact)
2910 .or_else(|| snapshot.steps.get(step))
2911 .and_then(|state| state.output.clone())
2912 .ok_or_else(|| {
2913 failure(
2914 WorkflowFailureCode::UnknownReference,
2915 format!("step output '{step}' unavailable in execution scope"),
2916 false,
2917 )
2918 })?;
2919 (output, pointer.as_str())
2920 }
2921 ValueRef::Item { name, pointer } => (
2922 self.items.get(name).cloned().ok_or_else(|| {
2923 failure(
2924 WorkflowFailureCode::UnknownReference,
2925 format!("map item '{name}' unavailable"),
2926 false,
2927 )
2928 })?,
2929 pointer.as_str(),
2930 ),
2931 ValueRef::Literal { value } => return Ok(value.clone()),
2932 };
2933 if pointer.is_empty() {
2934 Ok(root)
2935 } else {
2936 root.pointer(pointer).cloned().ok_or_else(|| {
2937 failure(
2938 WorkflowFailureCode::UnknownReference,
2939 format!("JSON pointer '{pointer}' not found"),
2940 false,
2941 )
2942 })
2943 }
2944 }
2945
2946 fn instance_id(&self, step_id: &str) -> String {
2950 if self.scope == "root" {
2951 return step_id.to_owned();
2952 }
2953 let mut id = format!("{step_id}@{}", self.scope);
2954 while self.compiled.steps.contains_key(&id) {
2955 id.push('@');
2956 }
2957 id
2958 }
2959
2960 fn step_is_in_scope(&self, state: &WorkflowStepSnapshot, step_id: &str) -> bool {
2961 state.topology.as_ref().map_or_else(
2962 || instance_is_in_scope(&state.id, step_id, &self.scope),
2963 |topology| {
2964 topology.planned_step_id == step_id
2965 && topology.map_index_path.starts_with(&self.map_index_path)
2966 },
2967 )
2968 }
2969
2970 fn check_cancelled(&self) -> Result<(), WorkflowFailure> {
2971 if self.cancellation.is_cancelled() || self.branch_cancellation.is_cancelled() {
2972 Err(failure(
2973 WorkflowFailureCode::Cancelled,
2974 "workflow cancelled",
2975 false,
2976 ))
2977 } else {
2978 Ok(())
2979 }
2980 }
2981}
2982
2983fn event(
2984 snapshot: &WorkflowRunSnapshot,
2985 step_id: Option<String>,
2986 kind: WorkflowRunEventKind,
2987) -> WorkflowRunEvent {
2988 WorkflowRunEvent {
2989 run_id: snapshot.run_id.clone(),
2990 sequence: snapshot.last_sequence,
2991 at: Utc::now(),
2992 step_id,
2993 kind,
2994 }
2995}
2996fn failure(
2997 code: WorkflowFailureCode,
2998 message: impl Into<String>,
2999 retryable: bool,
3000) -> WorkflowFailure {
3001 WorkflowFailure {
3002 code,
3003 message: message.into(),
3004 retryable,
3005 }
3006}
3007fn storage(error: std::io::Error) -> WorkflowRunError {
3008 WorkflowRunError::Storage(error.to_string())
3009}
3010fn exceeds_optional(requested: Option<u64>, ceiling: Option<u64>) -> bool {
3011 match (requested, ceiling) {
3012 (Some(requested), Some(ceiling)) => requested > ceiling,
3013 _ => false,
3014 }
3015}
3016
3017fn enforce_budget_within(
3018 requested: &WorkflowBudgets,
3019 ceiling: &WorkflowBudgets,
3020) -> Result<(), &'static str> {
3021 if requested.max_concurrency > ceiling.max_concurrency {
3022 Err("max_concurrency")
3023 } else if requested.max_agents > ceiling.max_agents {
3024 Err("max_agents")
3025 } else if requested.max_steps > ceiling.max_steps {
3026 Err("max_steps")
3027 } else if requested.max_retries > ceiling.max_retries {
3028 Err("max_retries")
3029 } else if requested.max_nesting_depth > ceiling.max_nesting_depth {
3030 Err("max_nesting_depth")
3031 } else if requested.wall_time_ms > ceiling.wall_time_ms {
3032 Err("wall_time_ms")
3033 } else if exceeds_optional(requested.max_tokens, ceiling.max_tokens) {
3034 Err("max_tokens")
3035 } else if exceeds_optional(requested.max_cost_micros, ceiling.max_cost_micros) {
3036 Err("max_cost_micros")
3037 } else {
3038 Ok(())
3039 }
3040}
3041
3042fn remaining_wall_time(snapshot: &WorkflowRunSnapshot) -> u64 {
3043 let elapsed = Utc::now()
3044 .signed_duration_since(snapshot.created_at)
3045 .num_milliseconds()
3046 .max(0) as u64;
3047 snapshot
3048 .definition
3049 .budgets
3050 .wall_time_ms
3051 .saturating_sub(elapsed)
3052}
3053
3054fn resolve_checkpoint_template(
3055 value: &Value,
3056 args: &Value,
3057 outputs: &BTreeMap<String, Value>,
3058) -> Result<Value, WorkflowRunError> {
3059 match value {
3060 Value::Object(object) if object.contains_key("from") => {
3061 let reference: ValueRef = serde_json::from_value(value.clone())
3062 .map_err(|_| WorkflowRunError::Preflight("invalid checkpoint reference".into()))?;
3063 let (root, pointer) = match &reference {
3064 ValueRef::Args { pointer } => (args, pointer),
3065 ValueRef::Step { step, pointer } => (
3066 outputs.get(step).ok_or_else(|| {
3067 WorkflowRunError::Preflight(
3068 "checkpoint reference is not in the completed prefix".into(),
3069 )
3070 })?,
3071 pointer,
3072 ),
3073 ValueRef::Literal { value } => return Ok(value.clone()),
3074 ValueRef::Item { .. } => {
3075 return Err(WorkflowRunError::Preflight(
3076 "map references are unsupported for continuation".into(),
3077 ))
3078 }
3079 };
3080 if pointer.is_empty() {
3081 Ok(root.clone())
3082 } else {
3083 root.pointer(pointer).cloned().ok_or_else(|| {
3084 WorkflowRunError::Preflight("checkpoint reference pointer is missing".into())
3085 })
3086 }
3087 }
3088 Value::Object(object) => object
3089 .iter()
3090 .map(|(key, value)| {
3091 Ok((
3092 key.clone(),
3093 resolve_checkpoint_template(value, args, outputs)?,
3094 ))
3095 })
3096 .collect::<Result<serde_json::Map<_, _>, _>>()
3097 .map(Value::Object),
3098 Value::Array(values) => values
3099 .iter()
3100 .map(|value| resolve_checkpoint_template(value, args, outputs))
3101 .collect::<Result<Vec<_>, _>>()
3102 .map(Value::Array),
3103 value => Ok(value.clone()),
3104 }
3105}
3106
3107fn definition_bundle_hash(bundle: &WorkflowDefinitionBundle) -> Result<String, WorkflowRunError> {
3108 let bytes = serde_json::to_vec(bundle)
3109 .map_err(|_| WorkflowRunError::Preflight("workflow bundle hashing failed".to_string()))?;
3110 Ok(hex::encode(Sha256::digest(bytes)))
3111}
3112fn parse_tool_result(result: ToolResult) -> Result<Value, WorkflowFailure> {
3113 if !result.success {
3114 return Err(failure(
3115 WorkflowFailureCode::ExecutionFailed,
3116 "workflow tool reported failure",
3117 true,
3118 ));
3119 }
3120 Ok(serde_json::from_str(&result.result).unwrap_or(Value::String(result.result)))
3121}
3122fn reject_secret_material(value: &Value) -> Result<(), String> {
3123 reject_secret_material_inner(value, false)
3124}
3125
3126fn reject_secret_material_in_definition(value: &Value) -> Result<(), String> {
3127 reject_secret_material_inner(value, true)
3128}
3129
3130fn reject_secret_material_inner(value: &Value, allow_bindings: bool) -> Result<(), String> {
3131 fn walk(value: &Value, key: Option<&str>, allow_bindings: bool) -> Result<(), String> {
3132 if value.as_object().is_some_and(|object| {
3133 object.len() == 1
3134 && object
3135 .get("$secret")
3136 .and_then(Value::as_str)
3137 .is_some_and(|handle| !handle.trim().is_empty())
3138 }) {
3139 return Ok(());
3140 }
3141 let safe_binding = allow_bindings
3142 && serde_json::from_value::<ValueRef>(value.clone())
3143 .is_ok_and(|reference| !matches!(reference, ValueRef::Literal { .. }));
3144 if key.is_some_and(|key| {
3145 let normalized = key
3146 .chars()
3147 .filter(|character| character.is_ascii_alphanumeric())
3148 .flat_map(char::to_lowercase)
3149 .collect::<String>();
3150 matches!(
3151 normalized.as_str(),
3152 "secret"
3153 | "token"
3154 | "password"
3155 | "credential"
3156 | "credentials"
3157 | "apikey"
3158 | "accesskey"
3159 | "accesstoken"
3160 | "secretkey"
3161 | "privatekey"
3162 )
3163 }) && !safe_binding
3164 {
3165 return Err("secret-bearing fields are not accepted by workflow runs".to_string());
3166 }
3167 if value.as_str().is_some_and(|value| {
3168 let trimmed = value.trim();
3169 trimmed.starts_with("capability://")
3170 || trimmed.starts_with("Bearer ")
3171 || trimmed.starts_with("sk-")
3172 || trimmed.starts_with("ghp_")
3173 || trimmed.starts_with("github_pat_")
3174 }) {
3175 return Err("opaque credential handles are not enabled for workflows".to_string());
3179 }
3180 match value {
3181 Value::Object(object) => {
3182 for (key, value) in object {
3183 if key == "properties" {
3184 let properties = value.as_object().ok_or_else(|| {
3185 "workflow schema properties must be an object".to_string()
3186 })?;
3187 for schema in properties.values() {
3188 walk(schema, None, allow_bindings)?;
3189 }
3190 } else {
3191 walk(value, Some(key), allow_bindings)?;
3192 }
3193 }
3194 }
3195 Value::Array(array) => {
3196 for value in array {
3197 walk(value, None, allow_bindings)?;
3198 }
3199 }
3200 _ => {}
3201 }
3202 Ok(())
3203 }
3204 walk(value, None, allow_bindings)
3205}
3206
3207fn contains_secret_handle(value: &Value) -> bool {
3208 match value {
3209 Value::Object(object) => {
3210 object.contains_key("$secret") || object.values().any(contains_secret_handle)
3211 }
3212 Value::Array(array) => array.iter().any(contains_secret_handle),
3213 _ => false,
3214 }
3215}
3216
3217fn contains_any_secret_material(value: &Value, secrets: &[String]) -> bool {
3218 let matches = |candidate: &str| {
3219 secrets
3220 .iter()
3221 .any(|secret| !secret.is_empty() && candidate.contains(secret))
3222 };
3223 fn walk(value: &Value, matches: &impl Fn(&str) -> bool) -> bool {
3224 match value {
3225 Value::String(value) => matches(value),
3226 Value::Object(object) => object
3227 .iter()
3228 .any(|(key, value)| matches(key) || walk(value, matches)),
3229 Value::Array(array) => array.iter().any(|value| walk(value, matches)),
3230 _ => false,
3231 }
3232 }
3233 walk(value, &matches)
3234}
3235
3236fn plan_leaf_count(plan: &WorkflowPlan) -> usize {
3237 match plan {
3238 WorkflowPlan::Step { .. } => 1,
3239 WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
3240 nodes.iter().fold(0usize, |total, node| {
3241 total.saturating_add(plan_leaf_count(node))
3242 })
3243 }
3244 WorkflowPlan::Choice {
3245 then_branch,
3246 else_branch,
3247 ..
3248 } => plan_leaf_count(then_branch).max(plan_leaf_count(else_branch)),
3249 WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3250 plan_leaf_count(body)
3251 }
3252 }
3253}
3254
3255fn plan_step_ids(plan: &WorkflowPlan) -> Vec<String> {
3256 match plan {
3257 WorkflowPlan::Step { step } => vec![step.clone()],
3258 WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
3259 nodes.iter().flat_map(plan_step_ids).collect()
3260 }
3261 WorkflowPlan::Choice {
3262 then_branch,
3263 else_branch,
3264 ..
3265 } => {
3266 let mut result = plan_step_ids(then_branch);
3267 result.extend(plan_step_ids(else_branch));
3268 result
3269 }
3270 WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3271 plan_step_ids(body)
3272 }
3273 }
3274}
3275
3276fn instance_is_in_scope(instance_id: &str, step_id: &str, scope: &str) -> bool {
3277 if scope == "root" {
3278 instance_id == step_id
3279 || instance_id
3280 .strip_prefix(&format!("{step_id}@root"))
3281 .is_some_and(|suffix| suffix.starts_with('['))
3282 } else {
3283 let exact = format!("{step_id}@{scope}");
3284 instance_id == exact
3285 || instance_id
3286 .strip_prefix(&exact)
3287 .is_some_and(|suffix| suffix.starts_with('['))
3288 }
3289}
3290
3291fn plan_frontier(plan: &WorkflowPlan) -> Vec<String> {
3292 match plan {
3293 WorkflowPlan::Step { step } => vec![step.clone()],
3294 WorkflowPlan::Sequence { nodes } => nodes.first().map_or_else(Vec::new, plan_frontier),
3295 WorkflowPlan::Parallel { nodes } => nodes.iter().flat_map(plan_frontier).collect(),
3296 WorkflowPlan::Choice {
3297 then_branch,
3298 else_branch,
3299 ..
3300 } => {
3301 let mut result = plan_frontier(then_branch);
3302 result.extend(plan_frontier(else_branch));
3303 result
3304 }
3305 WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3306 plan_frontier(body)
3307 }
3308 }
3309}
3310
3311fn validate_nested_input_contract(
3312 template: &Value,
3313 target_schema: &Value,
3314 compiled: &CompiledWorkflow,
3315) -> Result<(), String> {
3316 fn contains_ref(value: &Value) -> bool {
3317 match value {
3318 Value::Object(object) => {
3319 object.contains_key("from") || object.values().any(contains_ref)
3320 }
3321 Value::Array(array) => array.iter().any(contains_ref),
3322 _ => false,
3323 }
3324 }
3325 if !contains_ref(template) {
3326 return validate_schema(target_schema, template)
3327 .map_err(|error| format!("nested workflow input is incompatible: {error}"));
3328 }
3329 let reference: ValueRef = serde_json::from_value(template.clone()).map_err(|_| {
3330 "nested dynamic input schema cannot be proven compatible in phase 1".to_string()
3331 })?;
3332 let source_schema = match reference {
3333 ValueRef::Args { pointer } => {
3334 schema_at_pointer(&compiled.definition.input_schema, &pointer)
3335 }
3336 ValueRef::Step { step, pointer } => compiled
3337 .steps
3338 .get(&step)
3339 .and_then(|step| step.output_schema.as_ref())
3340 .and_then(|schema| schema_at_pointer(schema, &pointer)),
3341 ValueRef::Literal { value } => {
3342 return validate_schema(target_schema, &value)
3343 .map_err(|error| format!("nested workflow input is incompatible: {error}"));
3344 }
3345 ValueRef::Item { .. } => None,
3346 }
3347 .ok_or_else(|| {
3348 "nested dynamic input source schema is missing or pointer is invalid".to_string()
3349 })?;
3350 if schema_compatible(source_schema, target_schema) {
3351 Ok(())
3352 } else {
3353 Err("nested workflow input schema is not compatible with its pinned target".to_string())
3354 }
3355}
3356
3357fn schema_at_pointer<'a>(schema: &'a Value, pointer: &str) -> Option<&'a Value> {
3358 if pointer.is_empty() {
3359 return Some(schema);
3360 }
3361 let mut current = schema;
3362 for token in pointer.strip_prefix('/')?.split('/') {
3363 let token = token.replace("~1", "/").replace("~0", "~");
3364 current = if token.parse::<usize>().is_ok() {
3365 current.get("items")?
3366 } else {
3367 current.get("properties")?.get(&token)?
3368 };
3369 }
3370 Some(current)
3371}
3372
3373fn schema_compatible(source: &Value, target: &Value) -> bool {
3374 if source == target {
3375 return true;
3376 }
3377 let source_type = source.get("type").and_then(Value::as_str);
3378 let target_type = target.get("type").and_then(Value::as_str);
3379 source_type.is_some() && source_type == target_type && target_type != Some("object")
3380}
3381
3382fn effective_limits(requested: &WorkflowBudgets, ceilings: &WorkflowBudgets) -> WorkflowBudgets {
3383 WorkflowBudgets {
3384 max_concurrency: requested.max_concurrency.min(ceilings.max_concurrency),
3385 max_agents: requested.max_agents.min(ceilings.max_agents),
3386 max_steps: requested.max_steps.min(ceilings.max_steps),
3387 max_retries: requested.max_retries.min(ceilings.max_retries),
3388 max_nesting_depth: requested.max_nesting_depth.min(ceilings.max_nesting_depth),
3389 wall_time_ms: requested.wall_time_ms.min(ceilings.wall_time_ms),
3390 max_tokens: match (requested.max_tokens, ceilings.max_tokens) {
3391 (Some(requested), Some(ceiling)) => Some(requested.min(ceiling)),
3392 (Some(requested), None) => Some(requested),
3393 (None, ceiling) => ceiling,
3394 },
3395 max_cost_micros: match (requested.max_cost_micros, ceilings.max_cost_micros) {
3396 (Some(requested), Some(ceiling)) => Some(requested.min(ceiling)),
3397 (Some(requested), None) => Some(requested),
3398 (None, ceiling) => ceiling,
3399 },
3400 }
3401}