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