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